From f7e5391a86c5b9b3c5513f51c6bc2fd7aa9407f8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 7 Aug 2026 10:00:38 -0500 Subject: [PATCH 1/3] feat(sdk): MerkleExclusionProof + freeze-list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the wasm SDK's SealanceMerkleTree to Python as a program-agnostic MerkleExclusionProof, and add get_freeze_list to both network clients. Three deviations from the reference were fixed, each verified against the deployed shield_swap.aleo verifier: - getSiblingPath pads with `while (level < depth)`, which yields 15 siblings for every tree below the maximum and 16 only at depth 15. The struct is [field; 16], so a short path is rejected. Paths now fill max_depth + 1. - getLeafIndices brackets with `<=`, handing back indices whose proof fails the verifier's strict inequality — but only after the caller has paid to prove and broadcast. A member now raises instead. - maxNumLeaves was 2**(depth-1), half the contract's own cap. Now 2**depth. Leaf deduplication and the domain-separator selection are left as the reference has them: deduping client-side could produce a root disagreeing with the chain's, and the tree served by the endpoint is authoritative anyway. max_depth is the single configurable knob, mirroring SealanceMerkleTree.maxTreeDepth: capacity is 2**max_depth and a proof carries max_depth + 1 siblings, so the two cannot drift apart. Tests transcribe verify_merkle_non_inclusion from amm-v3 as an independent oracle that hashes via its own Poseidon4 calls, and assert it accepts every generated proof across all three verifier cases and every padding shape. The empty-tree root is pinned to shield_swap_freezelist.aleo's live root. --- sdk/python/aleo/__init__.py | 1 + sdk/python/aleo/async_network_client.py | 39 ++ sdk/python/aleo/merkle.py | 339 ++++++++++++++++++ sdk/python/aleo/network_client.py | 39 ++ sdk/python/tests/test_merkle.py | 318 ++++++++++++++++ sdk/python/tests/test_network_client.py | 49 +++ sdk/python/tests/test_network_client_async.py | 37 ++ 7 files changed, 822 insertions(+) create mode 100644 sdk/python/aleo/merkle.py create mode 100644 sdk/python/tests/test_merkle.py diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index df109e5e..45082d14 100644 --- a/sdk/python/aleo/__init__.py +++ b/sdk/python/aleo/__init__.py @@ -12,6 +12,7 @@ pass from .encryptor import * +from .merkle import MerkleExclusionProof as MerkleExclusionProof from .network_client import AleoNetworkClient as AleoNetworkClient from .async_network_client import AsyncAleoNetworkClient as AsyncAleoNetworkClient from ._client_common import AleoNetworkError as AleoNetworkError diff --git a/sdk/python/aleo/async_network_client.py b/sdk/python/aleo/async_network_client.py index 2ba8d2dd..66f66081 100644 --- a/sdk/python/aleo/async_network_client.py +++ b/sdk/python/aleo/async_network_client.py @@ -739,6 +739,45 @@ async def get_program_mapping_value( "getProgramMappingValue", ) + async def get_freeze_list(self, program_id: str) -> list[int]: + """Read a compliance program's freeze-list Merkle tree. + + Programs following the Sealance architecture publish their freeze list + as a sorted Merkle tree and require callers to prove non-inclusion in + it. Pair this with :class:`~aleo.MerkleExclusionProof` to turn the tree + into the proof a transition expects. + + Args: + program_id: The freeze-list program, e.g. + ``"shield_swap_freezelist.aleo"``. Each compliance program + keeps its own list, so this is a parameter rather than a + constant. + + Returns: + Every node of the tree in tree order — leaves first, then each layer + above, with the Merkle root last. An empty list reads back as the + two-leaf zero tree. + + Raises: + AleoNetworkError: If the program does not exist or serves no list. + ValueError: If the response is not an array of field values. + """ + payload = await self._get( + f"/programs/{program_id}/compliance/freeze-list", + "getFreezeList", + ) + if not isinstance(payload, list): + raise ValueError( + f"{program_id} returned a {type(payload).__name__} rather than " + f"a freeze list array" + ) + try: + return [int(str(node).strip().removesuffix("field")) for node in payload] + except ValueError: + raise ValueError( + f"{program_id} freeze list holds a non-numeric node" + ) from None + async def get_public_balance(self, address: str) -> int: """Read an address's public ``credits.aleo`` balance. diff --git a/sdk/python/aleo/merkle.py b/sdk/python/aleo/merkle.py new file mode 100644 index 00000000..27ff13b8 --- /dev/null +++ b/sdk/python/aleo/merkle.py @@ -0,0 +1,339 @@ +# Copyright (C) 2019-2026 Provable Inc. +# This file is part of the Aleo SDK library. + +# The Aleo SDK library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# The Aleo SDK library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with the Aleo SDK library. If not, see . + +"""Merkle exclusion (non-inclusion) proofs over sorted address trees. + +Programs following the Sealance compliance architecture keep a freeze list as a +sorted Merkle tree of addresses and require callers to prove their address is +*absent* from it. This module builds those proofs client-side. + +It is program-agnostic: it knows about sorted address trees and nothing about +any particular freeze list, so it serves ``shield_swap_freezelist.aleo``, the +compliance stablecoins, and anything else built the same way. + +Ported from the wasm SDK's ``SealanceMerkleTree`` +(``ProvableHQ/sdk@mainnet:sdk/src/integrations/sealance/merkle-tree.ts``). +""" +from __future__ import annotations + +from typing import Any, NamedTuple, Sequence + +from ._client_common import DEFAULT_NETWORK + +__all__ = ["MerkleExclusionProof", "SiblingPath"] + + +class SiblingPath(NamedTuple): + """One authentication path, as the verifier's ``MerkleProof`` struct. + + ``siblings[0]`` is the leaf itself and ``siblings[1]`` its leaf-layer + sibling; the rest are the sibling at each level above, zero-padded to fill + the struct's fixed-size array. + """ + + siblings: list[int] + leaf_index: int + +#: The all-zero address, which the tree carries as padding rather than as a +#: member. Verifiers compare against it, so it is never a real leaf. +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + +#: Domain separators. Leaf-layer pairs hash under ``1field``, every layer above +#: under ``0field``, which is what keeps a leaf from being replayed as a node. +_LEAF_PREFIX = "1field" +_NODE_PREFIX = "0field" + + +class MerkleExclusionProof: + """Builds Merkle non-inclusion proofs against a sorted address tree. + + Args: + max_depth: Maximum tree depth, matching the verifying program's + ``MAX_MERKLE_TREE_DEPTH``. This is the single knob the rest of the + shape derives from: the tree holds up to ``2 ** max_depth`` leaves + and a proof carries ``max_depth + 1`` siblings (one leaf slot plus + one per level). Defaults to 15, the shield_swap value. + network: Which extension module supplies ``Poseidon4`` and ``Address``. + """ + + def __init__(self, *, max_depth: int = 15, + network: str = DEFAULT_NETWORK) -> None: + if max_depth < 1: + raise ValueError(f"max_depth must be at least 1, got {max_depth}") + self.max_depth = max_depth + self._network = network + + def __repr__(self) -> str: + return (f"MerkleExclusionProof(max_depth={self.max_depth}, " + f"network={self._network!r})") + + def _net(self) -> Any: + """The network extension module supplying the hash and address types.""" + try: + if self._network == "testnet": + from . import testnet as _mod # type: ignore[attr-defined] + else: + from . import mainnet as _mod # type: ignore[attr-defined] + except ImportError: + raise ImportError( + f"aleo {self._network} module not available" + ) from None + return _mod + + # ── Tree construction ──────────────────────────────────────────────── + + def leaves_from_addresses(self, addresses: Sequence[str], *, + max_depth: int | None = None) -> list[str]: + """Addresses as sorted, zero-padded leaf literals. + + Zero addresses are dropped, the rest are converted to fields and sorted + ascending, then ``0field`` padding is *prepended* to reach a power of + two. Prepending is what keeps the greatest real address at the right + boundary, which is the invariant the above-last case relies on. + + Args: + addresses: The member addresses, in any order. + max_depth: Overrides the instance's depth for this call. + + Returns: + ``2 ** k`` field literals, ordered padding-first then ascending. + + Raises: + ValueError: If there are more addresses than ``2 ** max_depth``. + """ + depth = self.max_depth if max_depth is None else max_depth + capacity = 2 ** depth + + members = [a for a in addresses if a != ZERO_ADDRESS] + if len(members) > capacity: + raise ValueError( + f"freeze list holds {len(members)} addresses, but a depth-" + f"{depth} tree caps at {capacity}" + ) + + num_leaves = 2 if len(members) <= 1 else 1 << (len(members) - 1).bit_length() + + fields = sorted(self.address_to_field(a) for a in members) + padding = ["0field"] * (num_leaves - len(fields)) + return padding + [f"{f}field" for f in fields] + + def build_tree(self, leaves: Sequence[str]) -> list[int]: + """Hash *leaves* bottom-up into a flat tree array. + + Args: + leaves: An even number of field literals, as + :meth:`leaves_from_addresses` returns. + + Returns: + Every node as an int, level by level: leaves first, then each layer + above, ending with the root. + + Raises: + ValueError: If *leaves* is empty or has an odd length. + """ + if not leaves: + raise ValueError("leaves cannot be empty") + if len(leaves) % 2 != 0: + raise ValueError( + f"leaves must have an even length, got {len(leaves)}") + + current = list(leaves) + tree = list(current) + while len(current) > 1: + # The leaf layer is the only one whose width equals the input's. + prefix = _LEAF_PREFIX if len(current) == len(leaves) else _NODE_PREFIX + current = [ + self._hash_pair(prefix, current[i], current[i + 1]) + for i in range(0, len(current), 2) + ] + tree.extend(current) + return [int(node.removesuffix("field")) for node in tree] + + def tree_from_nodes(self, nodes: Sequence[str]) -> list[int]: + """A pre-built tree from its serialized nodes. + + Freeze-list services publish the whole tree — leaves, then each layer + above, root last — as decimal strings, so the common path needs no + hashing at all. + + Args: + nodes: Every node as a decimal string, in tree order. A ``field`` + suffix is tolerated. + + Returns: + The same nodes as ints, ready for :meth:`exclusion_proof`. + + Raises: + ValueError: If *nodes* is empty, is not a whole tree + (``2 * leaves - 1`` nodes), or holds a non-numeric entry. + """ + if not nodes: + raise ValueError("a tree needs at least one node") + try: + tree = [int(str(node).strip().removesuffix("field")) for node in nodes] + except ValueError as exc: + raise ValueError(f"tree holds a non-numeric node: {exc}") from None + + # A complete binary tree over 2**k leaves has 2**(k+1) - 1 nodes. + if (len(tree) + 1) & len(tree): + raise ValueError( + f"{len(tree)} nodes is not a complete tree — expected " + f"2 * leaves - 1" + ) + return tree + + def root(self, tree: Sequence[int]) -> str: + """The tree's root as a field literal.""" + if not tree: + raise ValueError("tree cannot be empty") + return f"{tree[-1]}field" + + # ── Proofs ─────────────────────────────────────────────────────────── + + def leaf_indices(self, tree: Sequence[int], address: str) -> tuple[int, int]: + """The two leaves whose paths together prove *address* is absent. + + Args: + tree: A flat tree as :meth:`build_tree` returns. + address: The address to exclude. + + Returns: + ``(left, right)``. Normally these bracket the address as adjacent + leaves. They collapse to a single index at the boundaries: both + ``0`` when the address sorts below every leaf, and both the last + index when it sorts above every leaf — the two special cases the + verifier checks separately. + + Raises: + ValueError: If *address* is a member. A member cannot be proven + absent, and returning indices anyway would produce a proof that + fails only after the caller has paid to prove and broadcast it. + """ + num_leaves = (len(tree) + 1) // 2 + leaves = list(tree[:num_leaves]) + value = self.address_to_field(address) + + if value in leaves: + raise ValueError( + f"{address} is on the list (leaf {leaves.index(value)}), so it " + f"cannot be proven excluded" + ) + + right = next((i for i, leaf in enumerate(leaves) if value <= leaf), -1) + if right == -1: + return num_leaves - 1, num_leaves - 1 + if right == 0: + return 0, 0 + return right - 1, right + + def sibling_path(self, tree: Sequence[int], leaf_index: int, *, + max_depth: int | None = None) -> SiblingPath: + """The authentication path for *leaf_index*. + + Args: + tree: A flat tree as :meth:`build_tree` returns. + leaf_index: Which leaf to authenticate. + max_depth: Overrides the instance's depth for this call. + + Returns: + A :class:`SiblingPath` holding exactly ``max_depth + 1`` siblings. + The array is padded to its full width rather than to the tree's own + depth: the verifier reads a fixed-size array and treats the trailing + zeros as "no more levels", so a short path fails to typecheck and a + long one cannot be represented. + + Raises: + IndexError: If *leaf_index* is outside the tree's leaf layer. + ValueError: If the tree is deeper than ``max_depth`` allows. + """ + depth = self.max_depth if max_depth is None else max_depth + width = depth + 1 + + num_leaves = (len(tree) + 1) // 2 + if not 0 <= leaf_index < num_leaves: + raise IndexError( + f"leaf_index {leaf_index} is outside a tree of {num_leaves} " + f"leaves" + ) + + siblings = [tree[leaf_index]] + index = leaf_index + parent_index = num_leaves + level = 1 + while parent_index < len(tree): + sibling = index + 1 if index % 2 == 0 else index - 1 + siblings.append(tree[sibling]) + index = parent_index + leaf_index // (2 ** level) + parent_index += num_leaves // (2 ** level) + level += 1 + + if len(siblings) > width: + raise ValueError( + f"tree of {num_leaves} leaves needs {len(siblings)} proof " + f"slots, but max_depth={depth} allows {width}" + ) + siblings.extend([0] * (width - len(siblings))) + return SiblingPath(siblings, leaf_index) + + def format_proof(self, paths: Sequence[SiblingPath]) -> str: + """Authentication paths as the Aleo array-of-struct literal.""" + structs = ", ".join( + "{siblings: [" + ", ".join(f"{s}field" for s in path.siblings) + + f"], leaf_index: {path.leaf_index}u32}}" + for path in paths + ) + return f"[{structs}]" + + def exclusion_proof(self, tree: Sequence[int], address: str, *, + max_depth: int | None = None) -> str: + """A ready-to-pass ``[MerkleProof; 2]`` literal proving *address* is absent. + + Args: + tree: A flat tree as :meth:`build_tree` or :meth:`tree_from_nodes` + returns. + address: The address to prove absent. + max_depth: Overrides the instance's depth for this call. + + Returns: + The literal to pass wherever the program takes ``[MerkleProof; 2]``. + + Raises: + ValueError: If *address* is on the list. + """ + left, right = self.leaf_indices(tree, address) + return self.format_proof([ + self.sibling_path(tree, left, max_depth=max_depth), + self.sibling_path(tree, right, max_depth=max_depth), + ]) + + # ── Primitives ─────────────────────────────────────────────────────── + + def address_to_field(self, address: str) -> int: + """An Aleo address as its field element, matching Leo's ``as field``.""" + net = self._net() + return int(str(net.Address.from_string(address).to_field()) + .removesuffix("field")) + + def _hash_pair(self, prefix: str, left: str, right: str) -> str: + """``Poseidon4`` over ``[prefix, left, right]`` as a field literal. + + The operands go through ``Plaintext`` so they hash as a 3-element array, + which is what the Leo side does. Hashing a bare list of fields, or + going through ``to_fields_raw``, gives a different and wrong answer. + """ + net = self._net() + plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]") + return str(net.Poseidon4().hash(plaintext.to_fields())) diff --git a/sdk/python/aleo/network_client.py b/sdk/python/aleo/network_client.py index ed8d3886..4aabfe38 100644 --- a/sdk/python/aleo/network_client.py +++ b/sdk/python/aleo/network_client.py @@ -740,6 +740,45 @@ def get_program_mapping_plaintext( ) return Plaintext.from_string(json.loads(raw)) + def get_freeze_list(self, program_id: str) -> list[int]: + """Read a compliance program's freeze-list Merkle tree. + + Programs following the Sealance architecture publish their freeze list + as a sorted Merkle tree and require callers to prove non-inclusion in + it. Pair this with :class:`~aleo.MerkleExclusionProof` to turn the tree + into the proof a transition expects. + + Args: + program_id: The freeze-list program, e.g. + ``"shield_swap_freezelist.aleo"``. Each compliance program + keeps its own list, so this is a parameter rather than a + constant. + + Returns: + Every node of the tree in tree order — leaves first, then each layer + above, with the Merkle root last. An empty list reads back as the + two-leaf zero tree. + + Raises: + AleoNetworkError: If the program does not exist or serves no list. + ValueError: If the response is not an array of field values. + """ + payload = self._get( + f"/programs/{program_id}/compliance/freeze-list", + "getFreezeList", + ) + if not isinstance(payload, list): + raise ValueError( + f"{program_id} returned a {type(payload).__name__} rather than " + f"a freeze list array" + ) + try: + return [int(str(node).strip().removesuffix("field")) for node in payload] + except ValueError: + raise ValueError( + f"{program_id} freeze list holds a non-numeric node" + ) from None + def get_public_balance(self, address: str) -> int: """Read an address's public ``credits.aleo`` balance. diff --git a/sdk/python/tests/test_merkle.py b/sdk/python/tests/test_merkle.py new file mode 100644 index 00000000..19c278ba --- /dev/null +++ b/sdk/python/tests/test_merkle.py @@ -0,0 +1,318 @@ +# Copyright (C) 2019-2026 Provable Inc. +# This file is part of the Aleo SDK library. + +# The Aleo SDK library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# The Aleo SDK library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with the Aleo SDK library. If not, see . + +"""Tests for :class:`aleo.MerkleExclusionProof`. + +The known answers come from deployed state rather than from the TypeScript +reference, so the port is pinned to what the chain actually verifies: + +- ``EMPTY_TREE_ROOT`` is ``shield_swap_freezelist.aleo``'s initial + ``freeze_list_root[1u8]``, read from testnet 2026-08-06. +- The address/field pairs are ``SealanceMerkleTree``'s own docstring examples + (``ProvableHQ/sdk@mainnet:sdk/src/integrations/sealance/merkle-tree.ts``). +""" + +import re + +import pytest + +from aleo import MerkleExclusionProof +from aleo.mainnet import Address, Plaintext, Poseidon4 + + +#: ``Poseidon4([1field, 0field, 0field])`` — the root of a two-leaf empty tree. +EMPTY_TREE_ROOT = ( + "3642222252059314292809609689035560016959342421640560347114299934615987159853field" +) + +ADDR_A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +ADDR_B = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t" +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + +#: Nine freeze-list members in ascending field order. Taking the first ``n`` +#: gives member counts either side of a power of two, which is what exercises +#: the zero padding. +MEMBERS = [ + "aleo10p6fdm4054p50e7ykx5f4ec5hy0gt5pwnm0xyj8rr5vx6pva5cqsyx5k5y", + "aleo13smp4u3ctasl5xnsrg9slcagsk384kvsk3enu9z0n3qsjcwjgcps4623cu", + "aleo1z0c4vn2erd0908020slz5muqm459c5kkyle2xy4jc3n5xh2w6uzq508wr7", + "aleo1zh8kzhr0eydshme7gjdj9ynf47pv6xg3al8mylx32yl4770t3vrqld2y2a", + "aleo15ls2v3ga6cxx34ddeqsm5f0ksrvwjaj60e8pztdyxu2fl6lpfyyqu93feh", + "aleo1wg0jwg4x9a2fmwhu2dkwdv2jd9j4vzmzp5gldjeyyzkmhj5lyy9q95m824", + "aleo1l6qyu9jl34ged8he4sfglcfuxjpupyyulvkjt2qa2qwj6s93ag9snnc929", + "aleo1fvx6rtmd6swdgwllgua6ygjjvej73nqj5uakzj9fe2sal8xe4cxsgr7lyq", + "aleo1fr5nxtkyflk83zq3jxr3w6tsa7akaj0eslvy83suu6pwut03958sjwvkdx", +] +#: Falls between ``MEMBERS[1]`` and ``MEMBERS[2]`` — the bracketed case. +TARGET_INSIDE = "aleo178tq4f3qpcggwgt4l0xdashky3k2tun2lfm9lu6dt0y7h309fsps4hszf8" +#: Below every member — the below-first case. +TARGET_BELOW = "aleo1x9m7n0vx0rd8am8qdr2f5m8u2e2zwzmj53yeh5jszl57yp5lqqqqjm8wh3" +#: Above every member — the above-last case. +TARGET_ABOVE = "aleo1c9pnkwzja5m5dj93dg80gjyale8ep0n8zgrwl8cpt0r0cu874gfqyzrcgf" + + +@pytest.fixture +def merkle() -> MerkleExclusionProof: + return MerkleExclusionProof() + + +# ── A independent reimplementation of the on-chain verifier ────────────────── +# +# Transcribed from ``verify_merkle_non_inclusion`` and +# ``calculate_merkle_root_and_depth`` in +# ``ProvableHQ/amm-v3@development:src/main.leo`` (b66d4f2), which matches the +# bytecode deployed as ``shield_swap.aleo``. It hashes via its own Poseidon4 +# calls rather than through the class under test, so it can disagree with the +# implementation — which is the point. + + +def _hash3(prefix: str, left: int, right: int) -> int: + plaintext = Plaintext.from_string(f"[{prefix},{left}field,{right}field]") + return int(str(Poseidon4().hash(plaintext.to_fields())).removesuffix("field")) + + +def parse_proof_literal(literal: str) -> list[tuple[list[int], int]]: + """``[{siblings: [...], leaf_index: Nu32}, ...]`` back into Python.""" + parsed = [] + for block in re.findall(r"\{[^{}]*\}", literal): + siblings = [int(s) for s in re.findall(r"(\d+)field", block)] + leaf_index = int(re.search(r"leaf_index:\s*(\d+)u32", block).group(1)) + parsed.append((siblings, leaf_index)) + return parsed + + +def leo_root_and_depth(siblings: list[int], leaf_index: int, + max_depth: int = 15) -> tuple[int, int]: + root = _hash3("1field", *( + (siblings[0], siblings[1]) if leaf_index % 2 == 0 + else (siblings[1], siblings[0]))) + for i in range(2, max_depth + 1): + if siblings[i] == 0: + return root, i - 1 + pair = ((root, siblings[i]) if (leaf_index // 2 ** (i - 1)) % 2 == 0 + else (siblings[i], root)) + root = _hash3("0field", *pair) + return root, max_depth + + +def leo_verify_non_inclusion(literal: str, address: str, + max_depth: int = 15) -> int: + """Return the proven root, or raise ``AssertionError`` as the contract aborts.""" + proofs = parse_proof_literal(literal) + assert len(proofs) == 2, "the verifier takes exactly two paths" + (sib0, idx0), (sib1, idx1) = proofs + + root0, depth0 = leo_root_and_depth(sib0, idx0, max_depth) + root1, depth1 = leo_root_and_depth(sib1, idx1, max_depth) + assert root0 == root1, "the two paths must share a root" + assert depth0 == depth1, "the two paths must share a depth" + + value = int(str(Address.from_string(address).to_field()).removesuffix("field")) + last_leaf_index = 2 ** depth0 - 1 + if idx0 == idx1: + if idx0 == 0: + assert value < sib0[0], "below-first: value must precede leaf 0" + else: + assert idx0 == last_leaf_index, "above-last: must be the final leaf" + assert value > sib0[0], "above-last: value must follow the final leaf" + else: + assert value > sib0[0], "bracketed: value must follow the left leaf" + assert value < sib1[0], "bracketed: value must precede the right leaf" + assert idx1 <= last_leaf_index, "bracketed: right leaf out of range" + assert idx0 + 1 == idx1, "bracketed: leaves must be adjacent" + return root0 + + +def synthetic_tree(merkle: MerkleExclusionProof, num_leaves: int) -> list[int]: + """A tree of *num_leaves* whose leaves are the fields ``10, 20, 30, ...``. + + Built directly from field literals rather than addresses so a test can + place a target value precisely between two members. + """ + leaves = [f"{(i + 1) * 10}field" for i in range(num_leaves)] + return merkle.build_tree(leaves) + + +def test_empty_freezelist_root_matches_the_deployed_root(merkle): + """An empty address list must reproduce the freezelist's on-chain root.""" + tree = merkle.build_tree(merkle.leaves_from_addresses([])) + + assert merkle.root(tree) == EMPTY_TREE_ROOT + + +@pytest.mark.parametrize("depth", range(1, 16)) +def test_sibling_path_always_fills_the_proof_array(merkle, depth): + """Every proof carries ``max_depth + 1`` siblings, whatever the tree depth. + + The reference implementation pads to ``depth`` instead, yielding 15 slots + for every tree below the maximum and 16 only at depth 15. The verifying + struct is ``[field; 16]``, so a short path is rejected outright. + + The path's width depends only on the tree's size, so the sweep uses stand-in + node values; :func:`test_sibling_path_of_a_hashed_tree_fills_the_array` + covers a genuinely hashed tree. + """ + num_leaves = 2 ** depth + tree = list(range(1, 2 * num_leaves)) + + path = merkle.sibling_path(tree, 0) + + assert len(path.siblings) == merkle.max_depth + 1 + + +def test_sibling_path_of_a_hashed_tree_fills_the_array(merkle): + """The width also holds for a real Poseidon-hashed tree.""" + tree = synthetic_tree(merkle, 8) + + path = merkle.sibling_path(tree, 3) + + assert len(path.siblings) == 16 + + +#: Eight members fill a tree exactly, so there is no padding and leaf 0 is a +#: real address. That is the only shape in which the verifier's below-first +#: case can arise. +UNPADDED = MEMBERS[:8] + + +def test_leaf_indices_bracket_an_absent_address(merkle): + """An address inside the range brackets the two leaves either side of it.""" + tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED)) + + assert merkle.leaf_indices(tree, TARGET_INSIDE) == (1, 2) + + +def test_leaf_indices_below_every_member_collapse_to_the_first_leaf(merkle): + """Below an unpadded list, both paths point at leaf 0.""" + tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED)) + + assert merkle.leaf_indices(tree, TARGET_BELOW) == (0, 0) + + +def test_leaf_indices_above_every_member_collapse_to_the_last_leaf(merkle): + """Above the whole list, both paths point at the final leaf.""" + tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED)) + + assert merkle.leaf_indices(tree, TARGET_ABOVE) == (7, 7) + + +def test_leaf_indices_bracket_against_padding_below_the_first_member(merkle): + """With padding, a below-everything address brackets the last zero leaf. + + Nine members pad to sixteen leaves, so leaf 6 is ``0field`` and leaf 7 is + the smallest real address. Every address sorts above zero, so this is the + ordinary bracketed case rather than the below-first one. + """ + tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS)) + + assert merkle.leaf_indices(tree, TARGET_BELOW) == (6, 7) + + +def test_leaf_indices_reject_an_address_that_is_on_the_list(merkle): + """A member cannot be proven absent, so ask for it and get an error. + + The reference brackets with ``<=``, which hands back indices whose proof + fails the verifier's strict inequality — but only after the caller has paid + to prove and broadcast it. + """ + tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS)) + + with pytest.raises(ValueError, match="on the list"): + merkle.leaf_indices(tree, MEMBERS[2]) + + +@pytest.mark.parametrize("target,case", [ + (TARGET_INSIDE, "bracketed"), + (TARGET_BELOW, "below-first"), + (TARGET_ABOVE, "above-last"), +]) +def test_exclusion_proof_satisfies_the_contract_verifier(merkle, target, case): + """Every generated proof is accepted by the transcribed on-chain verifier. + + This is what pins the port to the contract rather than to the TypeScript: + the oracle hashes independently, so a wrong ordering, index bit, or domain + separator shows up here. + """ + tree = merkle.build_tree(merkle.leaves_from_addresses(UNPADDED)) + + literal = merkle.exclusion_proof(tree, target) + + assert leo_verify_non_inclusion(literal, target) == tree[-1], case + + +@pytest.mark.parametrize("count", [0, 1, 2, 3, 5, 7, 9]) +@pytest.mark.parametrize("target", [TARGET_INSIDE, TARGET_BELOW, TARGET_ABOVE]) +def test_exclusion_proof_survives_zero_padded_trees(merkle, count, target): + """Member counts that are not powers of two pad with leading ``0field``.""" + tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS[:count])) + + literal = merkle.exclusion_proof(tree, target) + + assert leo_verify_non_inclusion(literal, target) == tree[-1] + + +def test_exclusion_proof_literal_matches_the_struct(merkle): + """The literal is two ``MerkleProof`` structs with full 16-slot arrays.""" + tree = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS)) + + literal = merkle.exclusion_proof(tree, TARGET_INSIDE) + + proofs = parse_proof_literal(literal) + assert len(proofs) == 2 + assert all(len(siblings) == 16 for siblings, _ in proofs) + assert literal.startswith("[{") and literal.endswith("}]") + + +def test_leaves_capacity_matches_the_depth(merkle): + """A depth-``d`` tree holds ``2 ** d`` addresses, not ``2 ** (d - 1)``.""" + assert len(MerkleExclusionProof(max_depth=2).leaves_from_addresses(MEMBERS[:4])) == 4 + with pytest.raises(ValueError, match="caps at 2"): + MerkleExclusionProof(max_depth=1).leaves_from_addresses(MEMBERS[:4]) + + +def test_tree_from_nodes_reads_the_freeze_list_endpoint_shape(merkle): + """The endpoint serves the whole tree as decimal strings, root last.""" + tree = merkle.tree_from_nodes( + ["0", "0", EMPTY_TREE_ROOT.removesuffix("field")]) + + assert merkle.root(tree) == EMPTY_TREE_ROOT + + +def test_tree_from_nodes_round_trips_a_built_tree(merkle): + """A served tree proves exactly like one built locally from addresses.""" + built = merkle.build_tree(merkle.leaves_from_addresses(MEMBERS)) + + served = merkle.tree_from_nodes([str(node) for node in built]) + + assert served == built + assert leo_verify_non_inclusion( + merkle.exclusion_proof(served, TARGET_INSIDE), TARGET_INSIDE) == built[-1] + + +def test_zero_addresses_are_not_members(merkle): + """The zero address is padding, so it never becomes a leaf of its own.""" + with_zero = merkle.leaves_from_addresses([ZERO_ADDRESS, *MEMBERS[:3]]) + + assert with_zero == merkle.leaves_from_addresses(MEMBERS[:3]) + + +def test_sibling_path_width_follows_a_custom_max_depth(merkle): + """``max_depth`` is the single knob: the array is ``max_depth + 1`` wide.""" + shallow = MerkleExclusionProof(max_depth=10) + tree = synthetic_tree(shallow, 8) + + assert len(shallow.sibling_path(tree, 0).siblings) == 11 + assert len(merkle.sibling_path(tree, 0, max_depth=4).siblings) == 5 diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py index 5c25cb8f..8123f168 100644 --- a/sdk/python/tests/test_network_client.py +++ b/sdk/python/tests/test_network_client.py @@ -875,3 +875,52 @@ def test_set_prover_uri() -> None: c = make_client() c.set_prover_uri("https://prover.example.com") assert c._prover_uri == f"https://prover.example.com/{NET}" + + +# --------------------------------------------------------------------------- +# Freeze list (compliance) +# --------------------------------------------------------------------------- + +@resp_lib.activate +def test_get_freeze_list_url_takes_the_program() -> None: + """The program is a parameter — one endpoint serves every freeze list.""" + resp_lib.add( + resp_lib.GET, + f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list", + json=["0", "0", "3642222252059314292809609689035560016959342421640560347114299934615987159853"], + ) + c = make_client() + + tree = c.get_freeze_list("shield_swap_freezelist.aleo") + + assert tree == [ + 0, 0, + 3642222252059314292809609689035560016959342421640560347114299934615987159853, + ] + assert resp_lib.calls[0].request.url == ( + f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list") + + +@resp_lib.activate +def test_get_freeze_list_serves_a_different_program() -> None: + resp_lib.add( + resp_lib.GET, + f"{HOST}/programs/test_usad_freezelist.aleo/compliance/freeze-list", + json=["0", "0", "17"], + ) + c = make_client() + + assert c.get_freeze_list("test_usad_freezelist.aleo") == [0, 0, 17] + + +@resp_lib.activate +def test_get_freeze_list_rejects_a_non_list_payload() -> None: + resp_lib.add( + resp_lib.GET, + f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list", + json={"unexpected": True}, + ) + c = make_client() + + with pytest.raises(ValueError, match="freeze list"): + c.get_freeze_list("shield_swap_freezelist.aleo") diff --git a/sdk/python/tests/test_network_client_async.py b/sdk/python/tests/test_network_client_async.py index 6d4f8171..6e3fb82b 100644 --- a/sdk/python/tests/test_network_client_async.py +++ b/sdk/python/tests/test_network_client_async.py @@ -596,3 +596,40 @@ def handler(req: httpx.Request) -> httpx.Response: assert result["ok"] is False assert result["status"] == 400 assert post_count == 1 + + +# --------------------------------------------------------------------------- +# Freeze list (compliance) +# --------------------------------------------------------------------------- + +async def test_get_freeze_list_url_takes_the_program() -> None: + """The program is a parameter — one endpoint serves every freeze list.""" + root = "3642222252059314292809609689035560016959342421640560347114299934615987159853" + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return jr(["0", "0", root]) + + c = make_client() + c._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + tree = await c.get_freeze_list("shield_swap_freezelist.aleo") + + assert tree == [0, 0, int(root)] + assert seen["url"] == ( + f"{HOST}/programs/shield_swap_freezelist.aleo/compliance/freeze-list") + + +async def test_get_freeze_list_serves_a_different_program() -> None: + c = make_client({"test_usad_freezelist.aleo/compliance/freeze-list": + jr(["0", "0", "17"])}) + + assert await c.get_freeze_list("test_usad_freezelist.aleo") == [0, 0, 17] + + +async def test_get_freeze_list_rejects_a_non_list_payload() -> None: + c = make_client({"compliance/freeze-list": jr({"unexpected": True})}) + + with pytest.raises(ValueError, match="freeze list"): + await c.get_freeze_list("shield_swap_freezelist.aleo") From c577f5916bdc334a76275a25f62158891aa83ff0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 7 Aug 2026 10:04:31 -0500 Subject: [PATCH 2/3] chore: snarkVM v4.8.1 -> v4.9.0 Rebuilt both network extensions. Core SDK 942 pass, shield-swap-sdk 237 pass, 8 skipped. Devnode binary updated separately to v0.2.3 (prebuilt release; the source build needs libclang for rocksdb-sys). --- sdk/Cargo.lock | 239 +++++++++++++++++++++++++------------------------ sdk/Cargo.toml | 2 +- 2 files changed, 121 insertions(+), 120 deletions(-) diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock index cdf626f6..44da47b2 100644 --- a/sdk/Cargo.lock +++ b/sdk/Cargo.lock @@ -25,7 +25,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aleo" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "hex", @@ -1289,9 +1289,9 @@ dependencies = [ [[package]] name = "k256" -version = "0.14.0-rc.15" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ce7f1aa9a24c53c6572d8017c8c1ceb5d44c6071ff68c9912860fa4d262101" +checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" dependencies = [ "cpubits", "ecdsa", @@ -2310,8 +2310,8 @@ dependencies = [ [[package]] name = "snarkvm" -version = "4.8.1" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "rand", @@ -2327,8 +2327,8 @@ dependencies = [ [[package]] name = "snarkvm-algorithms" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -2354,8 +2354,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2368,8 +2368,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-account" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-network", "snarkvm-circuit-types", @@ -2378,8 +2378,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-algorithms" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-types", "snarkvm-console-algorithms", @@ -2388,8 +2388,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-collections" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-types", @@ -2398,8 +2398,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "indexmap", @@ -2418,13 +2418,13 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment-witness" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" [[package]] name = "snarkvm-circuit-network" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-collections", @@ -2434,8 +2434,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-program" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2448,8 +2448,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-address", @@ -2463,8 +2463,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-address" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2476,8 +2476,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-boolean" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-console-types-boolean", @@ -2485,8 +2485,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-field" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2495,8 +2495,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-group" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2507,8 +2507,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-integers" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2519,8 +2519,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-scalar" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2530,8 +2530,8 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-string" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2542,8 +2542,8 @@ dependencies = [ [[package]] name = "snarkvm-console" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-account", "snarkvm-console-algorithms", @@ -2555,8 +2555,8 @@ dependencies = [ [[package]] name = "snarkvm-console-account" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "bs58", "snarkvm-console-network", @@ -2566,12 +2566,13 @@ dependencies = [ [[package]] name = "snarkvm-console-algorithms" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "blake2s_simd", "hex", "k256", + "rayon", "serde", "smallvec", "snarkvm-console-types", @@ -2582,8 +2583,8 @@ dependencies = [ [[package]] name = "snarkvm-console-collections" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "parking_lot", @@ -2595,8 +2596,8 @@ dependencies = [ [[package]] name = "snarkvm-console-network" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "enum-iterator", @@ -2615,8 +2616,8 @@ dependencies = [ [[package]] name = "snarkvm-console-network-environment" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "bech32", @@ -2633,8 +2634,8 @@ dependencies = [ [[package]] name = "snarkvm-console-program" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "enum-iterator", "enum_index", @@ -2654,8 +2655,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-address", @@ -2669,8 +2670,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types-address" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2680,16 +2681,16 @@ dependencies = [ [[package]] name = "snarkvm-console-types-boolean" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", ] [[package]] name = "snarkvm-console-types-field" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2698,8 +2699,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types-group" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2709,8 +2710,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types-integers" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2720,8 +2721,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types-scalar" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2731,8 +2732,8 @@ dependencies = [ [[package]] name = "snarkvm-console-types-string" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2742,8 +2743,8 @@ dependencies = [ [[package]] name = "snarkvm-curves" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "rand", "rustc_version", @@ -2755,8 +2756,8 @@ dependencies = [ [[package]] name = "snarkvm-fields" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -2772,8 +2773,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -2802,8 +2803,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-authority" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "rand", @@ -2814,8 +2815,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-block" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "indexmap", @@ -2838,8 +2839,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-committee" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "indexmap", "rayon", @@ -2850,8 +2851,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-ledger-narwhal-batch-certificate", "snarkvm-ledger-narwhal-batch-header", @@ -2863,8 +2864,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-certificate" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "indexmap", "rayon", @@ -2876,8 +2877,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-header" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "indexmap", "rayon", @@ -2888,8 +2889,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-data" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "bytes", "serde_json", @@ -2899,8 +2900,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-subdag" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "indexmap", "rayon", @@ -2914,8 +2915,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "bytes", "serde_json", @@ -2927,8 +2928,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission-id" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "snarkvm-console", "snarkvm-ledger-puzzle", @@ -2936,8 +2937,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -2956,8 +2957,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle-epoch" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -2978,8 +2979,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-query" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "async-trait", @@ -2995,8 +2996,8 @@ dependencies = [ [[package]] name = "snarkvm-ledger-store" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std-storage", "anyhow", @@ -3020,8 +3021,8 @@ dependencies = [ [[package]] name = "snarkvm-parameters" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -3042,8 +3043,8 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -3077,8 +3078,8 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-error" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "anyhow", "snarkvm-circuit-environment", @@ -3089,8 +3090,8 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-process" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "colored", @@ -3115,8 +3116,8 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-program" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "enum-iterator", "indexmap", @@ -3136,8 +3137,8 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-snark" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "bincode", "serde_json", @@ -3149,8 +3150,8 @@ dependencies = [ [[package]] name = "snarkvm-utilities" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "aleo-std", "anyhow", @@ -3172,8 +3173,8 @@ dependencies = [ [[package]] name = "snarkvm-utilities-derives" -version = "4.7.3" -source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.8.1#b7f0859592c75dd251430377240c7697a37ab899" +version = "4.9.0" +source = "git+https://github.com/ProvableHQ/snarkVM.git?tag=v4.9.0#8902106ccc810d784ae9edb90ebfde17f94ef41a" dependencies = [ "proc-macro2", "quote 1.0.46", diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 7444d415..948895c0 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -29,7 +29,7 @@ serde = "1" serde_json = "1" sha2 = "0.10" -snarkvm = { git = "https://github.com/ProvableHQ/snarkVM.git", tag = "v4.8.1", default-features = false, features = [ +snarkvm = { git = "https://github.com/ProvableHQ/snarkVM.git", tag = "v4.9.0", default-features = false, features = [ "console", "circuit", "synthesizer", "ledger", "utilities", "algorithms", "parameters", ] } From 6c29298f718b03f88abce6a0fc00d3b46e759280 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 7 Aug 2026 10:18:42 -0500 Subject: [PATCH 3/3] test(devnode): prove exclusion proofs against the deployed AMM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys shield_swap.aleo, shield_swap_freezelist.aleo, their multisig cores, and two plain ARC-20s onto a devnode — all fetched live from the node API rather than from repo fixtures, since the deployed programs are the authoritative statement of what the verifier accepts and a vendored copy can drift silently. Baked administrator literals are repointed to a genesis account so the stack can be configured locally. The freeze list is then populated and a mint runs, which is the transition requiring three separate non-inclusion proofs. Nothing on chain recomputes the root — the manager supplies it — so the test asserts the root the contract stores is the one MerkleExclusionProof computes, then proves against it. test_placeholder_proof_is_rejected is the control. The all-zero literal the SDK ships today actually clears the circuit: two depth-1 all-zero paths reconstruct the empty-tree root and every real address sorts above 0field, so it is a genuine non-inclusion proof for an empty tree. What stops it is the finalize's assert_valid_freeze_list_root once the list has entries. Without that control a passing mint would be evidence about the list, not the proofs. Also fixes devnode deployment fees. The devnode bundles its own snarkVM, which stopped agreeing with the bindings on deployment pricing at devnode 0.2.3 / snarkVM 4.9.0 — deployments were short by ~2.5% and rejected. This broke the existing devnode_amm fixture too. A rejected base fee names the required amount, so both fixtures now retry at that figure rather than carrying a guessed margin. No shield-swap-sdk signatures or plumbing changed: mint inputs are assembled directly in the test because the shipped method hardcodes the placeholder proof and takes no parameter to override it. --- sdk/python/aleo/merkle.py | 7 +- .../tests/integration/devnode_amm.py | 30 +- .../tests/integration/devnode_merkle_stack.py | 382 ++++++++++++++++++ .../tests/integration/test_devnode_merkle.py | 218 ++++++++++ 4 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 shield-swap-sdk/tests/integration/devnode_merkle_stack.py create mode 100644 shield-swap-sdk/tests/integration/test_devnode_merkle.py diff --git a/sdk/python/aleo/merkle.py b/sdk/python/aleo/merkle.py index 27ff13b8..189e8396 100644 --- a/sdk/python/aleo/merkle.py +++ b/sdk/python/aleo/merkle.py @@ -75,6 +75,9 @@ def __init__(self, *, max_depth: int = 15, raise ValueError(f"max_depth must be at least 1, got {max_depth}") self.max_depth = max_depth self._network = network + # Built on first use and reused: a full-depth tree is 65_535 hashes, + # and Poseidon setup is not free. + self._hasher: Any = None def __repr__(self) -> str: return (f"MerkleExclusionProof(max_depth={self.max_depth}, " @@ -335,5 +338,7 @@ def _hash_pair(self, prefix: str, left: str, right: str) -> str: going through ``to_fields_raw``, gives a different and wrong answer. """ net = self._net() + if self._hasher is None: + self._hasher = net.Poseidon4() plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]") - return str(net.Poseidon4().hash(plaintext.to_fields())) + return str(self._hasher.hash(plaintext.to_fields())) diff --git a/shield-swap-sdk/tests/integration/devnode_amm.py b/shield-swap-sdk/tests/integration/devnode_amm.py index e2171fb7..111454d2 100644 --- a/shield-swap-sdk/tests/integration/devnode_amm.py +++ b/shield-swap-sdk/tests/integration/devnode_amm.py @@ -134,18 +134,34 @@ def wait_queryable(self, program_id: str) -> None: def deploy_program(self, source: str, label: str) -> str: """Deploy *source* proofless: dummy verifying keys (no synthesis) and - an unproven public fee paid by the admin — devnode-only.""" + an unproven public fee paid by the admin — devnode-only. + + The devnode bundles its own snarkVM, which need not price deployments + identically to the bindings computing the fee here; the two diverged at + devnode 0.2.3 / snarkVM 4.9.0. A rejected base fee names the amount the + node wants, so a short fee is retried at that figure rather than + carrying a guessed margin. + """ net = self._net() process = self.aleo.process program = net.Program.from_source(source) - deployment = net.Deployment.from_program_unproven(program, self.admin.address) + cost = process.deployment_cost(deployment) - fee_auth = process.authorize_fee_public( - self.admin.private_key, cost, 0, deployment.deployment_id()) - fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root()) - tx = net.Transaction.from_deployment(self.admin.private_key, deployment, fee) - tx_id = self.submit_and_confirm(tx, f"deploy {label}") + for attempt in range(2): + fee_auth = process.authorize_fee_public( + self.admin.private_key, cost, 0, deployment.deployment_id()) + fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root()) + tx = net.Transaction.from_deployment(self.admin.private_key, deployment, fee) + try: + tx_id = self.submit_and_confirm(tx, f"deploy {label}") + break + except Exception as exc: + required = re.search(r"requires (\d+) microcredits", str(exc)) + if attempt or not required: + raise + cost = int(required.group(1)) + self.wait_queryable(label) # Later deployments/executions resolve this program from the process. process.add_program(program) diff --git a/shield-swap-sdk/tests/integration/devnode_merkle_stack.py b/shield-swap-sdk/tests/integration/devnode_merkle_stack.py new file mode 100644 index 00000000..9fe1046d --- /dev/null +++ b/shield-swap-sdk/tests/integration/devnode_merkle_stack.py @@ -0,0 +1,382 @@ +"""Devnode fixture for freeze-list Merkle exclusion proofs. + +Boots ``aleo-devnode``, deploys the shield_swap stack **fetched live from the +network** rather than from repo fixtures — the deployed programs are the +authoritative statement of what the verifier accepts, and a vendored copy can +drift from them silently. Then it populates the freeze list with real +addresses and leaves the stack ready for a transition that must prove +non-inclusion. + +This is what makes the proofs meaningful: against an *empty* freeze list the +all-zero placeholder literal verifies, so only a populated list exercises +:class:`aleo.MerkleExclusionProof` for real. + +Deployment is proofless (dummy verifying keys, unproven fee), as in +``devnode_amm``: synthesizing real keys for the AMM takes many minutes and buys +nothing on a node that skips certificate verification. + +Execution ladders: + +* **unproven** (default here): the authorization still *evaluates* the + transition, so ``verify_merkle_non_inclusion`` and its asserts run in full — + only the SNARK is skipped. The finalize, including + ``assert_valid_freeze_list_root``, runs on-chain either way. Both halves of + the proof are therefore checked. +* **proven** (``ALEO_DEVNODE_MERKLE_PROVEN=1``): full local proving, much + slower. +""" +from __future__ import annotations + +import os +import re +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +import requests + +from aleo import MerkleExclusionProof + +#: The node API is the source of truth for deployed program source. +NETWORK_API = os.environ.get( + "ALEO_DEVNODE_SOURCE_API", "https://api.provable.com/v2/testnet") + +AMM_PROGRAM = "shield_swap.aleo" +FREEZELIST_PROGRAM = "shield_swap_freezelist.aleo" +AMM_MULTISIG = "shield_swap_multisig_core.aleo" +TOKEN_MULTISIG = "test_arc20_multisig_core.aleo" +TOKEN0_PROGRAM = "test_arc20_usdc.aleo" +TOKEN1_PROGRAM = "test_arc20_usdt.aleo" + +#: Deployment order — imports before importers. +DEPLOY_ORDER = [ + AMM_MULTISIG, + FREEZELIST_PROGRAM, + AMM_PROGRAM, + TOKEN_MULTISIG, + TOKEN0_PROGRAM, + TOKEN1_PROGRAM, +] + +#: Administrator literals baked into the deployed programs. On a devnode these +#: keys do not exist, so every occurrence is rewritten to a genesis account. +#: The AMM and its freeze list share one; the test ARC-20s share another. +BAKED_ADMINS = [ + "aleo1z3zwzgpgakk89xpknync5rtklkjkyv33g7cvaqe0gku64zs3lv9qyux0qc", + "aleo1axurgcdhztu8m23ttzju38qzchtzs8kyk7nga9n58zyrmnxzmuqqf6wqdc", +] + +#: Freeze-list role bits: 8 grants roles, 16 edits the list. +FREEZELIST_MANAGER_ROLE = 24 +#: ARC-20 role bits: 8 grants roles, 1 mints. +TOKEN_MINTER_ROLE = 9 + +#: Blocks the previous freeze-list root stays valid after a rotation. +FREEZELIST_WINDOW = 100 +#: The devnode's snarkVM uses TEST consensus heights; the last activates at 20. +LAST_TEST_CONSENSUS_HEIGHT = 20 + +TOKEN_SUPPLY = 1_000_000_000_000 + +PROVEN = os.environ.get("ALEO_DEVNODE_MERKLE_PROVEN") == "1" + + +def identifier_to_field(identifier: str) -> str: + """A program identifier (no ``.aleo``) as the field the AMM keys tokens by. + + Little-endian bytes, verified against every entry of the live token + registry. + """ + value = 0 + for byte in reversed(identifier.encode()): + value = (value << 8) | byte + return f"{value}field" + + +def fetch_program(program_id: str) -> str: + """Deployed source for *program_id*, straight from the node API.""" + response = requests.get(f"{NETWORK_API}/program/{program_id}", timeout=60) + response.raise_for_status() + source = response.json() + if not isinstance(source, str) or f"program {program_id}" not in source: + raise RuntimeError(f"{program_id} did not come back as program source") + return source + + +def repoint_admins(source: str, admin_address: str) -> str: + """Rewrite every baked administrator literal to *admin_address*. + + Both the constructor gate and the finalize gates (``initialize``, + ``initialize_token``) compare against the same literal, so all occurrences + have to move together or the program deploys but cannot be configured. + """ + for baked in BAKED_ADMINS: + source = source.replace(baked, admin_address) + return source + + +@dataclass +class MerkleDevnode: + """A devnode running the network's shield_swap stack with a live freeze list.""" + + devnode: Any + aleo: Any + admin: Any + user: Any + sources: dict[str, str] + token0_id: str + token1_id: str + token0_program: str + token1_program: str + #: Addresses currently frozen, in insertion order. + frozen: list[str] = field(default_factory=list) + merkle: MerkleExclusionProof = field(default_factory=MerkleExclusionProof) + + # ── Chain plumbing ────────────────────────────────────────────────────── + + def _net(self) -> Any: + from aleo import testnet + return testnet + + def state_root(self) -> str: + return str(self.aleo.network.get_state_root()) + + def submit_and_confirm(self, tx: Any, label: str) -> str: + tx_id = self.aleo.network.submit_transaction(tx) + self.devnode.advance(1) + confirmed = str(self.aleo.network.get_confirmed_transaction(str(tx_id))) + if '"accepted"' not in confirmed and "'accepted'" not in confirmed: + raise RuntimeError( + f"{label}: transaction {tx_id} was not accepted: " + f"{confirmed[:400]}\ndevnode logs:\n" + + "\n".join(self.devnode.logs()[-15:]) + ) + return str(tx_id) + + def wait_queryable(self, program_id: str) -> None: + for _ in range(20): + try: + if f"program {program_id}" in str( + self.aleo.network.get_program(program_id)): + return + except Exception: + pass + self.devnode.advance(1) + time.sleep(0.3) + raise RuntimeError(f"{program_id} never became queryable") + + def deploy_program(self, program_id: str) -> str: + """Deploy proofless: dummy verifying keys and an unproven public fee. + + The devnode ships its own snarkVM, which need not price deployments + identically to the bindings computing the fee here. When the node + rejects the base fee it names the amount it wants, so a short fee is + retried at that figure rather than guessing a margin. + """ + net = self._net() + process = self.aleo.process + program = net.Program.from_source(self.sources[program_id]) + deployment = net.Deployment.from_program_unproven(program, self.admin.address) + + cost = process.deployment_cost(deployment) + for attempt in range(2): + fee_auth = process.authorize_fee_public( + self.admin.private_key, cost, 0, deployment.deployment_id()) + fee = net.Fee.from_authorization_unproven(fee_auth, self.state_root()) + tx = net.Transaction.from_deployment( + self.admin.private_key, deployment, fee) + try: + tx_id = self.submit_and_confirm(tx, f"deploy {program_id}") + break + except Exception as exc: + required = re.search(r"requires (\d+) microcredits", str(exc)) + if attempt or not required: + raise + cost = int(required.group(1)) + + self.wait_queryable(program_id) + # Registers the program for later executions and dynamic dispatch. + process.add_program(program) + return tx_id + + def execute(self, account: Any, program_id: str, function: str, + inputs: list[Any], label: str) -> str: + """Run one transition as *account* and require on-chain acceptance.""" + bound = self.aleo.programs.get(program_id).functions[function](*inputs) + + if PROVEN: + return self.submit_and_confirm( + bound.build_transaction(account).raw, label) + # authorize() evaluates the transition, so in-circuit asserts — the + # Merkle verification included — run here even though no SNARK follows. + return self.submit_and_confirm(self._unproven_tx(bound, account), label) + + def submit_call(self, call: Any, account: Any, label: str) -> str: + """Drive a ``DexCall`` through the same ladder as :meth:`execute`. + + Used for the transitions that take no Merkle proof (pool creation), so + those keep going through the shipped client rather than hand-built + inputs. + """ + if PROVEN: + result = call.transact(account) + self.devnode.advance(1) + return str(result.transaction_id) + return self.submit_and_confirm( + self._unproven_tx(call._bound, account), label) + + def _unproven_tx(self, bound: Any, account: Any) -> Any: + """A proofless execution transaction for an already-bound call.""" + net = self._net() + process = self.aleo.process + auth = bound.authorize(account).raw + root = self.state_root() + execution = net.Execution.from_authorization_unproven(auth, root) + cost, _ = process.execution_cost(execution) + fee_auth = process.authorize_fee_public( + account.private_key, cost, 0, execution.execution_id) + fee = net.Fee.from_authorization_unproven(fee_auth, root) + return net.Transaction.from_execution(execution, fee) + + def records_of(self, account: Any, tx_id: str) -> list[str]: + tx = self.aleo.network.get_transaction_object(tx_id) + return [str(r) for r in tx.owned_records(account.view_key)] + + def privatize_token(self, account: Any, token_program: str, + amount: int) -> str: + tx_id = self.execute( + account, token_program, "transfer_public_to_private", + [str(account.address), f"{amount}u128"], + f"privatize {amount} {token_program}") + records = [r for r in self.records_of(account, tx_id) if "amount" in r] + if not records: + raise RuntimeError(f"no Token record from {token_program} (tx {tx_id})") + return records[0] + + def read_mapping(self, program_id: str, mapping: str, key: str) -> Optional[str]: + from aleo_shield_swap._core import normalize_mapping_value + return normalize_mapping_value( + self.aleo.programs.get(program_id).mapping(mapping).get(key)) + + # ── Freeze list ───────────────────────────────────────────────────────── + + def on_chain_root(self) -> str: + """The freeze list's current root, as the AMM's finalize reads it.""" + root = self.read_mapping(FREEZELIST_PROGRAM, "freeze_list_root", "1u8") + if root is None: + raise RuntimeError("freeze list has no root — initialize first") + return root + + def local_tree(self) -> list[int]: + """The tree over the currently frozen addresses, built locally.""" + return self.merkle.build_tree( + self.merkle.leaves_from_addresses(self.frozen)) + + def freeze(self, address: str) -> str: + """Add *address* to the freeze list, rotating the root to match. + + The contract stores whatever root the manager hands it, so this is + where the locally computed root becomes the one every later proof must + reproduce. Entries occupy indices from 1 upward; index 0 is the zero + address written at initialization. + """ + previous_root = self.on_chain_root() + self.frozen.append(address) + new_root = self.merkle.root(self.local_tree()) + tx_id = self.execute( + self.admin, FREEZELIST_PROGRAM, "update_freeze_list", + [address, "true", f"{len(self.frozen)}u32", previous_root, new_root], + f"freeze {address[:12]}…") + return tx_id + + def exclusion_proof(self, address: str) -> str: + """A ``[MerkleProof; 2]`` literal proving *address* is not frozen.""" + return self.merkle.exclusion_proof(self.local_tree(), address) + + def stop(self) -> None: + self.devnode.stop() + + +def setup_merkle_devnode(freeze_count: int = 5) -> MerkleDevnode: + """Boot a devnode with the network's stack deployed and a populated list. + + Args: + freeze_count: How many generated addresses to put on the freeze list. + Anything above zero makes the placeholder proof invalid, which is + the point. + """ + from aleo.testing import Devnode + + devnode = Devnode().start() + aleo = devnode.aleo + admin = devnode.accounts[0] + aleo.default_account = admin + aleo.record_provider = None # no scanner on a devnode + devnode.advance(LAST_TEST_CONSENSUS_HEIGHT + 2) + + sources = { + pid: repoint_admins(fetch_program(pid), str(admin.address)) + for pid in DEPLOY_ORDER + } + + usdc_id = identifier_to_field(TOKEN0_PROGRAM.removesuffix(".aleo")) + usdt_id = identifier_to_field(TOKEN1_PROGRAM.removesuffix(".aleo")) + # Pools order their sides by token id, not by name. + usdc_first = int(usdc_id.removesuffix("field")) < int(usdt_id.removesuffix("field")) + + ctx = MerkleDevnode( + devnode=devnode, aleo=aleo, admin=admin, user=None, sources=sources, + token0_id=usdc_id if usdc_first else usdt_id, + token1_id=usdt_id if usdc_first else usdc_id, + token0_program=TOKEN0_PROGRAM if usdc_first else TOKEN1_PROGRAM, + token1_program=TOKEN1_PROGRAM if usdc_first else TOKEN0_PROGRAM, + ) + + for program_id in DEPLOY_ORDER: + ctx.deploy_program(program_id) + + # Freeze list: initialize grants the admin role 8 (grant roles only), so it + # has to promote itself to 24 before it can edit the list. + ctx.execute(admin, FREEZELIST_PROGRAM, "initialize", + [str(admin.address), f"{FREEZELIST_WINDOW}u32"], + "freezelist initialize") + ctx.execute(admin, FREEZELIST_PROGRAM, "update_role", + [str(admin.address), f"{FREEZELIST_MANAGER_ROLE}u16"], + "freezelist grant manager role") + + # Tokens: same shape — initialize grants role 8, minting needs bit 1. + for token_program in (TOKEN0_PROGRAM, TOKEN1_PROGRAM): + ctx.execute(admin, token_program, "initialize_token", + [str(admin.address)], f"initialize {token_program}") + ctx.execute(admin, token_program, "update_role", + [str(admin.address), f"{TOKEN_MINTER_ROLE}u16"], + f"grant minter on {token_program}") + + # AMM admin configuration, mirroring the deployed testnet parameters. + for label, function, inputs in [ + ("fee tier 3000", "add_fee_tier", ["3000u16"]), + ("tick spacing 60", "add_tick_spacing", ["60u32"]), + ("bind 3000->60", "bind_fee_to_tick_spacing", ["3000u16", "60u32"]), + ("allow token0", "allow_token", [ctx.token0_id, ctx.token0_id]), + ("allow token1", "allow_token", [ctx.token1_id, ctx.token1_id]), + ("open pool creation", "set_pool_creation_is_open", ["true"]), + ]: + ctx.execute(admin, AMM_PROGRAM, function, inputs, f"admin {label}") + + # A funded non-admin does the proving work, so the signer under test is not + # the same account that administers the list. + user = aleo.account.create() + ctx.user = user + ctx.execute(admin, "credits.aleo", "transfer_public", + [str(user.address), "100000000u64"], "fund user") + for token_program in (TOKEN0_PROGRAM, TOKEN1_PROGRAM): + ctx.execute(admin, token_program, "mint_public", + [str(user.address), f"{TOKEN_SUPPLY}u128"], + f"mint {token_program} to user") + + # Populate the list with addresses that are NOT the user, so the user can + # still prove non-inclusion against a genuinely non-empty tree. + for _ in range(freeze_count): + ctx.freeze(str(aleo.account.create().address)) + + return ctx diff --git a/shield-swap-sdk/tests/integration/test_devnode_merkle.py b/shield-swap-sdk/tests/integration/test_devnode_merkle.py new file mode 100644 index 00000000..51945573 --- /dev/null +++ b/shield-swap-sdk/tests/integration/test_devnode_merkle.py @@ -0,0 +1,218 @@ +"""Freeze-list Merkle exclusion proofs against the real deployed AMM. + +Deploys the network's ``shield_swap.aleo``, ``shield_swap_freezelist.aleo``, +and two plain ARC-20s onto a devnode, populates the freeze list, and mints a +position — the transition that requires three separate non-inclusion proofs +(signer, recipient, withdrawal). + +The unit tests in ``sdk/python/tests/test_merkle.py`` check the port against a +Python transcription of the verifier. This checks it against the verifier +itself, compiled into the deployed bytecode, which is the only thing that +settles whether the proofs are actually right. + +``test_placeholder_proof_is_rejected`` is the control: it shows the all-zero +literal the SDK ships today stops working the moment the list is non-empty, so +the passing mint is evidence about the proofs rather than about a list that +happens to accept anything. + +Run with:: + + pytest tests/integration/test_devnode_merkle.py -m devnode +""" +from __future__ import annotations + +import pytest + +from aleo import MerkleExclusionProof +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap import _generated as g +from aleo_shield_swap._core import default_merkle_proofs, generate_field_nonce + +from .devnode_merkle_stack import ( + AMM_PROGRAM, + FREEZELIST_PROGRAM, + MerkleDevnode, + setup_merkle_devnode, +) + +pytestmark = pytest.mark.devnode + +FEE = 3000 +INITIAL_TICK = 0 +MINT_AMOUNT = 100_000_000 +FUND_AMOUNT = 200_000_000 + +#: ``Poseidon4([1field, 0field, 0field])`` — the root while the list is empty. +EMPTY_TREE_ROOT = ( + "3642222252059314292809609689035560016959342421640560347114299934615987159853field" +) + + +@pytest.fixture(scope="module") +def ctx() -> MerkleDevnode: + try: + context = setup_merkle_devnode(freeze_count=5) + except Exception as exc: + if "aleo-devnode not found" in str(exc): + pytest.skip(f"aleo-devnode binary not available: {exc}") + raise + yield context + context.stop() + + +@pytest.fixture(scope="module") +def dex(ctx: MerkleDevnode) -> ShieldSwap: + client = ShieldSwap(ctx.aleo, program=AMM_PROGRAM) + ctx.aleo.default_account = ctx.user + return client + + +@pytest.fixture(scope="module") +def pool_key(ctx: MerkleDevnode, dex: ShieldSwap) -> str: + """A live pool over the two deployed ARC-20s.""" + call = dex.create_pool( + token0_id=ctx.token0_id, token1_id=ctx.token1_id, + fee=FEE, initial_tick=INITIAL_TICK, account=ctx.user) + ctx.submit_call(call, ctx.user, "create_pool") + key = dex.derive_pool_key(ctx.token0_id, ctx.token1_id, FEE) + assert ctx.read_mapping(AMM_PROGRAM, "pools", key), "pool was not created" + return key + + +def build_mint_inputs(ctx: MerkleDevnode, dex: ShieldSwap, pool_key: str, *, + signer_proofs: str, recipient_proofs: str, + withdrawal_proofs: str) -> list[str]: + """``mint`` inputs in deployed order, with the proofs left to the caller. + + Mirrors ``ShieldSwap.mint``'s assembly. It is rebuilt here rather than + reused because the shipped method hardcodes the placeholder proof and takes + no parameter to override it. + """ + slot = dex.get_slot(pool_key) + spacing = slot.tick_spacing + lower, upper = -10 * spacing, 10 * spacing + + lower_hint = dex.find_tick_predecessor(pool_key, lower) + upper_pred = dex.find_tick_predecessor(pool_key, upper) + # The finalize inserts the lower tick before validating the upper hint. + upper_hint = lower if lower > upper_pred else upper_pred + + request = g.MintPositionRequest( + pool=pool_key, tick_lower=lower, tick_upper=upper, + amount0_desired=MINT_AMOUNT, amount1_desired=MINT_AMOUNT, + amount0_min=0, amount1_min=0, + tick_lower_hint=lower_hint, tick_upper_hint=upper_hint, + ).to_plaintext() + + record0 = ctx.privatize_token(ctx.user, ctx.token0_program, FUND_AMOUNT) + record1 = ctx.privatize_token(ctx.user, ctx.token1_program, FUND_AMOUNT) + + return [ + generate_field_nonce(), record0, record1, + str(ctx.user.address), str(ctx.user.address), request, + ctx.token0_id, ctx.token1_id, + signer_proofs, recipient_proofs, withdrawal_proofs, + ] + + +def test_freeze_list_root_matches_the_locally_built_tree(ctx: MerkleDevnode): + """The root the contract stores is the one the port computes. + + Nothing on chain recomputes the root — the manager supplies it — so this is + the step that ties the local tree to the value every proof must reproduce. + """ + assert len(ctx.frozen) == 5 + assert ctx.on_chain_root() == ctx.merkle.root(ctx.local_tree()) + + +def test_freeze_list_is_no_longer_empty(ctx: MerkleDevnode): + """Without this, a passing mint would prove nothing about the proofs.""" + assert ctx.on_chain_root() != EMPTY_TREE_ROOT + + +def test_frozen_addresses_cannot_be_proven_absent(ctx: MerkleDevnode): + """A member is rejected client-side rather than on-chain.""" + with pytest.raises(ValueError, match="on the list"): + ctx.exclusion_proof(ctx.frozen[0]) + + +def test_placeholder_proof_is_rejected(ctx: MerkleDevnode, dex: ShieldSwap, + pool_key: str): + """The all-zero literal fails against a populated list. + + This is the control for :func:`test_mint_with_exclusion_proofs_succeeds`: + without it, a mint could pass because the list accepts anything. + + The placeholder clears the *circuit* — two copies of a depth-1 all-zero + path reconstruct the empty-tree root, and every real address sorts above + ``0field``, so it is a genuine non-inclusion proof for an empty tree. What + stops it is the finalize's ``assert_valid_freeze_list_root``, which no + longer matches once the list has entries. The transaction is therefore + *rejected* on chain rather than failing to authorize. + """ + placeholder = default_merkle_proofs() + inputs = build_mint_inputs( + ctx, dex, pool_key, signer_proofs=placeholder, + recipient_proofs=placeholder, withdrawal_proofs=placeholder) + + with pytest.raises(RuntimeError, match="was not accepted") as excinfo: + ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs, + "mint with placeholder proofs") + + assert "'status': 'rejected'" in str(excinfo.value), ( + "expected the finalize's root check to reject the placeholder") + + +def test_mint_with_exclusion_proofs_succeeds(ctx: MerkleDevnode, dex: ShieldSwap, + pool_key: str): + """A real proof carries a mint through the deployed verifier. + + The signer, recipient, and withdrawal address are all the user here, so the + same proof satisfies all three checks — the contract verifies each + independently and requires all three roots to agree. + """ + proof = ctx.exclusion_proof(str(ctx.user.address)) + inputs = build_mint_inputs( + ctx, dex, pool_key, signer_proofs=proof, + recipient_proofs=proof, withdrawal_proofs=proof) + + tx_id = ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs, + "mint with exclusion proofs") + + assert tx_id + nfts = [r for r in ctx.records_of(ctx.user, tx_id) if "token_id" in r] + assert nfts, "mint produced no PositionNFT" + + +def test_proof_still_verifies_after_the_list_grows(ctx: MerkleDevnode, + dex: ShieldSwap, + pool_key: str): + """Freezing another address rotates the root; a fresh proof tracks it.""" + before = ctx.on_chain_root() + ctx.freeze(str(ctx.aleo.account.create().address)) + after = ctx.on_chain_root() + assert after != before, "freezing did not rotate the root" + + proof = ctx.exclusion_proof(str(ctx.user.address)) + inputs = build_mint_inputs( + ctx, dex, pool_key, signer_proofs=proof, + recipient_proofs=proof, withdrawal_proofs=proof) + + assert ctx.execute(ctx.user, AMM_PROGRAM, "mint", inputs, + "mint after root rotation") + + +def test_exclusion_proof_from_the_served_tree(ctx: MerkleDevnode): + """A tree read back as serialized nodes proves the same as a built one. + + This is the shape ``AleoNetworkClient.get_freeze_list`` returns, so it + covers the path a caller takes when the list comes from a service rather + than from a local rebuild. + """ + built = ctx.local_tree() + + served = MerkleExclusionProof().tree_from_nodes([str(n) for n in built]) + + assert served == built + assert (MerkleExclusionProof().exclusion_proof(served, str(ctx.user.address)) + == ctx.exclusion_proof(str(ctx.user.address)))