From 78671533d1b7eb19a09675d7242143a6ff7c5475 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Tue, 20 Oct 2026 09:45:13 -0400 Subject: [PATCH 01/12] feat: resolve freeze-list compliance proofs automatically in the bridge and Shield Swap SDKs Add aleo.compliance (tree math, MerkleProof/MerkleProofPair, witness verification, freeze_list_program, FreezeListProofs/AsyncFreezeListProofs with a per-client cache pinned to each list's root) and the freeze-list endpoint on both network clients and facade network modules. Bridge: unshield, xreserve.burn and execute take compliance_proofs=; the merkle_proof= keyword remains as an alias. Shield Swap: aleo_shield_swap.compliance plans the proof slots of every entrypoint (swap, claim, mint, increase, collect, all rebalance variants) per MerkleProofReference.md; the write methods take compliance_proofs= as a tuple in input order (typed per-call unions on the bundle count) and build every bundle themselves when it is unset; wrapper_proofs= remains as an alias. Live-verified on testnet (swap, claim, USDCx leg, liquidity lifecycle) and on mainnet (USDCx unshield, private xReserve burns to Ethereum and Arc). --- bridge-sdk/AGENTS.md | 37 +- bridge-sdk/README.md | 39 + bridge-sdk/pyrightconfig.json | 2 +- bridge-sdk/python/aleo_bridge/AGENTS.md | 37 +- bridge-sdk/python/aleo_bridge/__init__.py | 3 +- bridge-sdk/python/aleo_bridge/client.py | 24 +- bridge-sdk/python/aleo_bridge/freezelist.py | 204 +++--- bridge-sdk/python/aleo_bridge/lifecycle.py | 9 +- bridge-sdk/python/aleo_bridge/privacy.py | 26 +- bridge-sdk/python/aleo_bridge/xreserve.py | 30 +- bridge-sdk/tests/conftest.py | 16 + bridge-sdk/tests/fakes/fake_bridge.py | 4 +- bridge-sdk/tests/live/test_compliance_live.py | 73 ++ bridge-sdk/tests/test_client_lifecycle.py | 3 +- bridge-sdk/tests/test_freezelist.py | 94 ++- bridge-sdk/tests/test_privacy.py | 2 +- bridge-sdk/tests/test_xreserve.py | 4 +- sdk/python/aleo/__init__.py | 10 + sdk/python/aleo/_client_common.py | 24 +- sdk/python/aleo/async_network_client.py | 20 + sdk/python/aleo/compliance.py | 666 ++++++++++++++++++ sdk/python/aleo/facade/async_client.py | 9 + sdk/python/aleo/facade/network.py | 17 + sdk/python/aleo/network_client.py | 31 + sdk/python/tests/test_compliance.py | 423 +++++++++++ sdk/python/tests/test_facade_async.py | 10 + sdk/python/tests/test_facade_network.py | 9 + sdk/python/tests/test_network_client.py | 42 ++ sdk/python/tests/test_network_client_async.py | 22 + shield-swap-sdk/AGENTS.md | 28 +- shield-swap-sdk/README.md | 52 ++ .../python/aleo_shield_swap/AGENTS.md | 28 +- .../python/aleo_shield_swap/__init__.py | 12 + .../python/aleo_shield_swap/_core.py | 14 +- .../python/aleo_shield_swap/async_client.py | 74 +- .../python/aleo_shield_swap/client.py | 152 +++- .../python/aleo_shield_swap/compliance.py | 233 ++++++ shield-swap-sdk/tests/conftest.py | 30 +- .../tests/integration/test_compliance_live.py | 107 +++ shield-swap-sdk/tests/test_async_client.py | 19 +- shield-swap-sdk/tests/test_claim.py | 24 +- shield-swap-sdk/tests/test_compliance.py | 283 ++++++++ shield-swap-sdk/tests/test_gen_context.py | 6 +- shield-swap-sdk/tests/test_liquidity.py | 10 +- 44 files changed, 2690 insertions(+), 272 deletions(-) create mode 100644 bridge-sdk/tests/live/test_compliance_live.py create mode 100644 sdk/python/aleo/compliance.py create mode 100644 sdk/python/tests/test_compliance.py create mode 100644 shield-swap-sdk/python/aleo_shield_swap/compliance.py create mode 100644 shield-swap-sdk/tests/integration/test_compliance_live.py create mode 100644 shield-swap-sdk/tests/test_compliance.py diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index df96b7b2..90ed9f8d 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -79,7 +79,7 @@ solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. -### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -92,8 +92,10 @@ journal id is reserved before submission and stays fixed through recovery. changing on-chain receipt identity. ``proving`` is ``"delegate"`` (DPS) or ``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or ``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default -private; ``record``/``merkle_proof`` optional — the SDK selects a record -and computes the exclusion proof). The Hyperlane hook payment is +private; ``record``/``compliance_proofs`` optional — the SDK selects a +record and computes the freeze-list exclusion proof, caching it until +the list changes; ``merkle_proof`` is the earlier name for one literal +bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is pinned. Irreversible once the source step is broadcast: afterwards use ``wait`` / ``recover``, never ``execute`` again. @@ -274,11 +276,19 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. ``asset`` is an asset ref, a ``Route`` or a route id (BAT/USDG need the route: two destinations). -### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` -Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a -freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than -the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. +Burn USDCx for USDC on the selected EVM route (Ethereum by default). + +``mode="private"`` spends a USDCx Token record through the wrapper and +proves the signer is not on the USDCx freeze list. Both inputs are +resolved from chain state when not supplied: the record is the +smallest one covering the amount, and the exclusion proof is computed +from the published list and cached until the list changes. Pass one +``[MerkleProof; 2]`` bundle in ``compliance_proofs`` to use it as given +(``merkle_proof`` is the earlier name for the literal form). The amount +must exceed the 2 USDCx withdrawal fee. The Aleo burn-attestation +service forwards accepted burns to Circle. ### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` @@ -292,13 +302,22 @@ One Circle request for *message_hash*; ``None`` while pending (404). -### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +Move a private record into the public balance; see ``privacy.unshield``. +An ARC-22 token (USDCx) needs a freeze-list exclusion proof for the +signer: computed and cached by the SDK when ``compliance_proofs`` is +unset, or taken as given from one ``[MerkleProof; 2]`` bundle. ### `freezelist.exclusion_proof(self, address: 'str', program: 'str') -> 'str'` -``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty. +``[MerkleProof; 2]`` proving *address* is not frozen on *program*; the empty pair when the list is empty. + +Reads the current root every call and the published tree only when +the root changed. Raises ``ConfigurationError`` when the root is +unreadable, the published tree does not match it, or *address* is on +the list. ### `eth.transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'EvmCall[DispatchReceipt]'` diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 6bf11cc8..fc98d4ca 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -654,6 +654,45 @@ supplying `record=` avoids the hosted scanner. Shielding and unshielding each submit a separate Aleo transaction with a fee. A failed conversion does not reverse or repeat the bridge transfer. +### Compliance proofs for USDCx + +USDCx is an ARC-22 token: every private transfer checks that the signer is +not on the token's freeze list. A USDCx unshield and a private xReserve +withdrawal therefore take one `[MerkleProof; 2]` argument — a bundle of two +Merkle paths proving the signer's address is absent from +`usdcx_freezelist.aleo`. + +By default the SDK builds that bundle. It reads the list's current root, +fetches the published tree only when the root changed since the last call, +verifies every witness against the root, and reuses the result until the +list changes. While the list is empty (its state on both networks as of +2026-10-20) the fixed empty-tree bundle is used and nothing beyond the root is +read. A signer that is on the list gets `FrozenAddressError` before any fee is +paid. + +```python +receipt = bridge.unshield("aleo/usdcx", amount="2.5").delegate() # proof built by the SDK +progress = bridge.execute(quote.plan) # private burn: proof built by the SDK +``` + +Pass `compliance_proofs=` to use a bundle built elsewhere. The value is the +encoded literal, a `MerkleProofPair`, or a `(MerkleProof, MerkleProof)` tuple; +a one-element tuple of any of these is accepted too. `merkle_proof=` is the +earlier name for the literal form and still works. + +```python +from aleo_bridge import MerkleProof, MerkleProofPair + +bundle = MerkleProofPair(MerkleProof(siblings=(0,) * 16, leaf_index=1), + MerkleProof(siblings=(0,) * 16, leaf_index=1)) +receipt = bridge.unshield("aleo/usdcx", amount="2.5", compliance_proofs=bundle).delegate() +``` + +`bridge.freezelist.exclusion_proof(address, "usdcx_stablecoin.aleo")` returns +the bundle for any address, and `bridge.freezelist.clear_cache()` forgets the +cached trees. The published tree comes from +`aleo.network.get_program_freeze_list(program)`. + ## Understand transfer costs A bridge transfer can incur transaction fees, relay costs, and provider fees diff --git a/bridge-sdk/pyrightconfig.json b/bridge-sdk/pyrightconfig.json index 973333f4..d6f8efc4 100644 --- a/bridge-sdk/pyrightconfig.json +++ b/bridge-sdk/pyrightconfig.json @@ -1,6 +1,6 @@ { "include": ["python/aleo_bridge"], - "extraPaths": ["python"], + "extraPaths": ["python", "../sdk/python"], "venvPath": ".", "venv": ".venv", "reportMissingModuleSource": false diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index df96b7b2..90ed9f8d 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -79,7 +79,7 @@ solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. -### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -92,8 +92,10 @@ journal id is reserved before submission and stays fixed through recovery. changing on-chain receipt identity. ``proving`` is ``"delegate"`` (DPS) or ``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or ``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default -private; ``record``/``merkle_proof`` optional — the SDK selects a record -and computes the exclusion proof). The Hyperlane hook payment is +private; ``record``/``compliance_proofs`` optional — the SDK selects a +record and computes the freeze-list exclusion proof, caching it until +the list changes; ``merkle_proof`` is the earlier name for one literal +bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is pinned. Irreversible once the source step is broadcast: afterwards use ``wait`` / ``recover``, never ``execute`` again. @@ -274,11 +276,19 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. ``asset`` is an asset ref, a ``Route`` or a route id (BAT/USDG need the route: two destinations). -### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` -Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a -freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than -the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. +Burn USDCx for USDC on the selected EVM route (Ethereum by default). + +``mode="private"`` spends a USDCx Token record through the wrapper and +proves the signer is not on the USDCx freeze list. Both inputs are +resolved from chain state when not supplied: the record is the +smallest one covering the amount, and the exclusion proof is computed +from the published list and cached until the list changes. Pass one +``[MerkleProof; 2]`` bundle in ``compliance_proofs`` to use it as given +(``merkle_proof`` is the earlier name for the literal form). The amount +must exceed the 2 USDCx withdrawal fee. The Aleo burn-attestation +service forwards accepted burns to Circle. ### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` @@ -292,13 +302,22 @@ One Circle request for *message_hash*; ``None`` while pending (404). -### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +Move a private record into the public balance; see ``privacy.unshield``. +An ARC-22 token (USDCx) needs a freeze-list exclusion proof for the +signer: computed and cached by the SDK when ``compliance_proofs`` is +unset, or taken as given from one ``[MerkleProof; 2]`` bundle. ### `freezelist.exclusion_proof(self, address: 'str', program: 'str') -> 'str'` -``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty. +``[MerkleProof; 2]`` proving *address* is not frozen on *program*; the empty pair when the list is empty. + +Reads the current root every call and the published tree only when +the root changed. Raises ``ConfigurationError`` when the root is +unreadable, the published tree does not match it, or *address* is on +the list. ### `eth.transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'EvmCall[DispatchReceipt]'` diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 4f49fb3f..e0eca84f 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -29,7 +29,7 @@ from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .eth import Ethereum, EthModule # noqa: E402 -from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList # noqa: E402 +from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProof, MerkleProofPair, ProofBundle # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 from .lifecycle import prepare # noqa: E402 @@ -51,6 +51,7 @@ "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", + "MerkleProof", "MerkleProofPair", "ProofBundle", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointLoadResult", "CheckpointProblem", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 54f74fdf..9ae47baa 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -20,7 +20,7 @@ from .cctp import CctpModule from .errors import BridgeError, ConfigurationError, UnsupportedRouteError from .eth import Ethereum, EthModule -from .freezelist import FreezeList +from .freezelist import FreezeList, ProofBundle from .hyperlane import HyperlaneModule from .privacy import PrivacyModule from .profile import DEFAULT_ENDPOINT, Profile @@ -268,9 +268,16 @@ def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = return self.privacy.shield(asset, amount=amount, amount_atomic=amount_atomic, recipient=recipient) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + """Move a private record into the public balance; see ``privacy.unshield``. + + An ARC-22 token (USDCx) needs a freeze-list exclusion proof for the + signer: computed and cached by the SDK when ``compliance_proofs`` is + unset, or taken as given from one ``[MerkleProof; 2]`` bundle. + """ return self.privacy.unshield(asset, amount=amount, amount_atomic=amount_atomic, record=record, - merkle_proof=merkle_proof, recipient=recipient) + compliance_proofs=compliance_proofs, merkle_proof=merkle_proof, recipient=recipient) # ── status ── def _public_balance(self, asset: Asset, address: str) -> int: @@ -370,7 +377,8 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N mint_mode=mint_mode, secret_nonce=secret_nonce, **({"cctp": cctp} if cctp is not None else {})) def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: str | None = None, - record: str | None = None, merkle_proof: str | None = None, + record: str | None = None, compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, + merkle_proof: str | None = None, gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0): """Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -384,14 +392,16 @@ def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: changing on-chain receipt identity. ``proving`` is ``"delegate"`` (DPS) or ``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or ``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default - private; ``record``/``merkle_proof`` optional — the SDK selects a record - and computes the exclusion proof). The Hyperlane hook payment is + private; ``record``/``compliance_proofs`` optional — the SDK selects a + record and computes the freeze-list exclusion proof, caching it until + the list changes; ``merkle_proof`` is the earlier name for one literal + bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is pinned. Irreversible once the source step is broadcast: afterwards use ``wait`` / ``recover``, never ``execute`` again. """ return _lifecycle.execute(self, plan, on_checkpoint=on_checkpoint, proving=proving, mode=mode, - record=record, merkle_proof=merkle_proof, + record=record, compliance_proofs=compliance_proofs, merkle_proof=merkle_proof, gas_payment_microcredits=gas_payment_microcredits, secret_nonce=secret_nonce, poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index 050f4184..c12e34f4 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -1,125 +1,87 @@ -"""Sealance compliance tree — Merkle exclusion proofs for ARC-22 (USDCx) private transfers and burns. - -Port of ``sdk/src/integrations/sealance/merkle-tree.ts``. Leaves are frozen addresses as little-endian -field ints, sorted ascending and front-padded with ``0field`` to a power of two (minimum two). The leaf -level hashes ``Poseidon4([1field, l, r])``, inner levels ``Poseidon4([0field, l, r])`` — with the -three-element array packed through ``Plaintext.to_fields()``, exactly as the Leo program does. A sibling -path starts with the leaf itself, then one sibling per level, zero-padded to ``depth`` entries. -The deployed ``MerkleProof`` struct is ``[field; 16]`` + ``u32``, so proofs use ``PROOF_SIBLINGS = 16``. +"""Freeze-list exclusion proofs for ARC-22 (USDCx) private transfers and burns. + +The tree math and the per-client proof cache live in :mod:`aleo.compliance`; +this module keeps the bridge's names for them and the ``bridge.freezelist`` +module that reads a token's list straight from its mappings. """ from __future__ import annotations -import math from typing import TYPE_CHECKING -from . import encoding as enc +from aleo.compliance import ( # noqa: F401 — re-exported under the bridge's historical names + CURRENT_ROOT_KEY, + DEFAULT_DEPTH, + EMPTY_MERKLE_PROOF_PAIR, + EMPTY_TREE_ROOT, + FREEZE_LIST_INDEX_MAPPING, + FREEZE_LIST_LAST_INDEX_MAPPING, + FREEZE_LIST_MAPPING, + FREEZE_LIST_ROOT_MAPPING, + KNOWN_FREEZE_LISTS, + PROOF_SIBLINGS, + ZERO_ADDRESS, + FreezeListError, + FreezeListProofs, + FrozenAddressError, + MerkleProof, + MerkleProofPair, + ProofBundle, + address_to_field_int, + build_tree, + format_merkle_proof, + generate_leaves, + hash_two, + leaf_indices, + one_bundle, + sibling_path, +) + from .errors import ConfigurationError if TYPE_CHECKING: # pragma: no cover from .client import Bridge -ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" -DEFAULT_DEPTH = 15 # TS getSiblingPath default; tree capacity 2**(depth-1) leaves -PROOF_SIBLINGS = 16 # struct MerkleProof { siblings: [field; 16u32], leaf_index: u32 } -FREEZE_LIST_MAPPING = "freeze_list" # address => bool (frozen flag, NOT the list) -FREEZE_LIST_LAST_INDEX_MAPPING = "freeze_list_last_index" # bool => u32, keyed "true" -FREEZE_LIST_INDEX_MAPPING = "freeze_list_index" # u32 => address (the ordered list; [0u32] = zero-address sentinel) -FREEZE_LIST_ROOT_MAPPING = "freeze_list_root" # u8 => field, keyed "1u8" (current) / "2u8" (previous) -CURRENT_ROOT_KEY = "1u8" -# Token program -> its freeze-list program, for when Bridge.program(token).imports is unavailable. -FREEZE_LIST_PROGRAMS = { - "usdcx_stablecoin.aleo": "usdcx_freezelist.aleo", - "test_usdcx_stablecoin.aleo": "test_usdcx_freezelist.aleo", -} -EMPTY_TREE_ROOT = 3642222252059314292809609689035560016959342421640560347114299934615987159853 -_EMPTY_PROOF = "{ siblings: [" + ", ".join(["0field"] * PROOF_SIBLINGS) + "], leaf_index: 1u32 }" -EMPTY_MERKLE_PROOF_PAIR = f"[{_EMPTY_PROOF}, {_EMPTY_PROOF}]" - - -def address_to_field_int(address: str) -> int: - """bech32m payload bytes read little-endian — the field element a Leo program sees for an address.""" - return int.from_bytes(enc.aleo_address_to_bytes32(address), "little") - - -def hash_two(prefix: str, left: str, right: str, network: str) -> str: - """``Poseidon4(Plaintext("[prefix,left,right]").to_fields())`` as a ``…field`` literal.""" - net = enc.network_module(network) - plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]") - return str(net.Poseidon4().hash(plaintext.to_fields())) - - -def generate_leaves(addresses: list[str], depth: int = DEFAULT_DEPTH) -> list[str]: - """Frozen addresses → sorted ``…field`` leaves, zero address dropped, front-padded to a power of two (min 2).""" - live = [a for a in addresses if a != ZERO_ADDRESS] - max_leaves = 2 ** (depth - 1) - if len(live) > max_leaves: - raise ConfigurationError(f"Leaves limit exceeded. Max: {max_leaves}, provided: {len(live)}") - count = 2 if len(live) <= 1 else 2 ** math.ceil(math.log2(len(live))) - fields = sorted(address_to_field_int(a) for a in live) - return ["0field"] * (count - len(fields)) + [f"{f}field" for f in fields] - - -def build_tree(leaves: list[str], network: str) -> list[int]: - """Bottom-up tree as ints: leaves first, root last (the layout the TS SDK and compliance API use).""" - if not leaves: - raise ConfigurationError("Leaves array cannot be empty") - if len(leaves) % 2: - raise ConfigurationError("Leaves array must have even number of elements") - tree = list(leaves) - level = list(leaves) - while len(level) > 1: - prefix = "1field" if len(level) == len(leaves) else "0field" - level = [hash_two(prefix, level[i], level[i + 1], network) for i in range(0, len(level), 2)] - tree.extend(level) - return [int(node[: -len("field")]) for node in tree] - - -def leaf_indices(tree: list[int], address: str) -> tuple[int, int]: - """(left, right) leaf indices bracketing *address* for a non-inclusion proof (TS ``getLeafIndices``).""" - count = (len(tree) + 1) // 2 - target = address_to_field_int(address) - leaves = tree[:count] - right = next((i for i, leaf in enumerate(leaves) if target <= leaf), -1) - left = right - 1 - if right == -1: - right = left = count - 1 - if right == 0: - left = 0 - return left, right - - -def sibling_path(tree: list[int], index: int, depth: int = DEFAULT_DEPTH) -> list[int]: - """Leaf, then the sibling at each level, zero-padded to *depth* entries (TS ``getSiblingPath``).""" - count = (len(tree) + 1) // 2 - path = [tree[index]] - node, parent, level = index, count, 1 - while parent < len(tree): - sibling = node + 1 if node % 2 == 0 else node - 1 - path.append(tree[sibling]) - node = parent + index // 2 ** level - parent += count // 2 ** level - level += 1 - while len(path) < depth: - path.append(0) - return path - - -def format_merkle_proof(left: tuple[list[int], int], right: tuple[list[int], int]) -> str: - """``[MerkleProof; 2]`` literal with veil's spacing: ``{ siblings: [a, b], leaf_index: Nu32 }``.""" - parts = [] - for siblings, index in (left, right): - parts.append("{ siblings: [" + ", ".join(f"{s}field" for s in siblings) + f"], leaf_index: {index}u32 }}") - return "[" + ", ".join(parts) + "]" +#: Token program -> its freeze-list program, for when ``Bridge.program(token).imports`` is unavailable. +FREEZE_LIST_PROGRAMS = {program: freezelist for program, freezelist in KNOWN_FREEZE_LISTS.items() if freezelist} + + +def resolve_one_bundle(compliance_proofs: ProofBundle | tuple[ProofBundle] | None, merkle_proof: str | None) -> str | None: + """The single ``[MerkleProof; 2]`` literal a bridge call was given, or ``None`` to compute one. + + Raises: + ConfigurationError: Both arguments were given, or the value is not a proof bundle. + """ + try: + return one_bundle(compliance_proofs, merkle_proof) + except FreezeListError as exc: + raise ConfigurationError(str(exc)) from exc class FreezeList: - """``bridge.freezelist`` — reads a compliant token's frozen addresses and proves an address is not among them.""" + """``bridge.freezelist`` — prove that an address is not on a compliant token's freeze list. + + ``exclusion_proof`` is what every private USDCx unshield and burn needs. It + reads the list's current root, fetches the published tree only when that + root changed since the last call, and reuses verified witnesses while it + is unchanged. While the list is empty the fixed empty-tree pair is + returned. ``leaves`` and ``tree`` read the list entry by entry from the + program's mappings instead, for callers that want to inspect it. + """ def __init__(self, bridge: "Bridge") -> None: self._bridge = bridge + self._proofs = FreezeListProofs(bridge.aleo) + + def clear_cache(self) -> None: + """Forget every cached tree and witness; the next proof reads the chain again.""" + self._proofs.clear() def freeze_list_program(self, token_program: str) -> str: - """The freeze-list program backing *token_program* (usually a token program that imports it).""" + """The freeze-list program backing *token_program* (usually a token program that imports it). + + Raises: + ConfigurationError: *token_program* names no freeze list. + """ if token_program.endswith("freezelist.aleo"): return token_program from aleo.facade.errors import AleoError, ProgramNotFound @@ -133,7 +95,7 @@ def freeze_list_program(self, token_program: str) -> str: fallback = FREEZE_LIST_PROGRAMS.get(token_program) if fallback: return fallback - raise ConfigurationError(f"{token_program} has no freeze-list program; pass merkle_proof explicitly") + raise ConfigurationError(f"{token_program} has no freeze-list program; pass compliance_proofs explicitly") def leaves(self, program: str) -> list[str]: """Frozen addresses from *program*'s freeze-list ``freeze_list_index`` mapping (0..last inclusive), @@ -146,7 +108,7 @@ def leaves(self, program: str) -> list[str]: count = int(last.removesuffix("u32")) except ValueError as exc: raise ConfigurationError(f"{fl_program}/{FREEZE_LIST_LAST_INDEX_MAPPING} returned {last!r}, expected a u32") from exc - addresses = [] + addresses: list[str] = [] for index in range(count + 1): value = self._bridge.mapping_value(fl_program, FREEZE_LIST_INDEX_MAPPING, f"{index}u32") if value and value != ZERO_ADDRESS: @@ -158,7 +120,7 @@ def _verified_tree(self, fl_program: str, leaves: list[str]) -> list[int]: on_chain_root = self._bridge.mapping_value(fl_program, FREEZE_LIST_ROOT_MAPPING, CURRENT_ROOT_KEY) if on_chain_root is None: raise ConfigurationError( - f"{fl_program}/{FREEZE_LIST_ROOT_MAPPING}[{CURRENT_ROOT_KEY}] is unreadable; pass merkle_proof= explicitly") + f"{fl_program}/{FREEZE_LIST_ROOT_MAPPING}[{CURRENT_ROOT_KEY}] is unreadable; pass compliance_proofs= explicitly") computed_root = f"{tree[-1]}field" if computed_root != on_chain_root: raise ConfigurationError( @@ -167,27 +129,27 @@ def _verified_tree(self, fl_program: str, leaves: list[str]) -> list[int]: return tree def tree(self, program: str) -> list[int]: - """Merkle tree over *program*'s frozen addresses, verified against the on-chain root when readable.""" + """Merkle tree over *program*'s frozen addresses (read from its mappings), verified against the on-chain root.""" return self._verified_tree(self.freeze_list_program(program), self.leaves(program)) def exclusion_proof(self, address: str, program: str) -> str: - """``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty.""" + """``[MerkleProof; 2]`` proving *address* is not frozen on *program*; the empty pair when the list is empty. + + Reads the current root every call and the published tree only when + the root changed. Raises ``ConfigurationError`` when the root is + unreadable, the published tree does not match it, or *address* is on + the list. + """ fl_program = self.freeze_list_program(program) - leaves = self.leaves(program) - tree = self._verified_tree(fl_program, leaves) # verifies the on-chain root, empty list included - if not leaves: - return EMPTY_MERKLE_PROOF_PAIR - count = (len(tree) + 1) // 2 - target = address_to_field_int(address) - if target in tree[:count]: - raise ConfigurationError(f"{address} is on the {program} freeze list; no exclusion proof exists for it") - left, right = leaf_indices(tree, address) - return format_merkle_proof((sibling_path(tree, left, PROOF_SIBLINGS), left), - (sibling_path(tree, right, PROOF_SIBLINGS), right)) + try: + return self._proofs.exclusion_proof(address, fl_program) + except FreezeListError as exc: + raise ConfigurationError(str(exc)) from exc __all__ = ["CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_PROGRAMS", "FREEZE_LIST_ROOT_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", + "FreezeListError", "FrozenAddressError", "MerkleProof", "MerkleProofPair", "ProofBundle", "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", - "leaf_indices", "sibling_path"] + "leaf_indices", "one_bundle", "sibling_path"] diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index d7c86170..e710ee56 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -557,7 +557,7 @@ def _mint_secret(plan: Plan, secret_nonce: str | None) -> str: # ── execute ─────────────────────────────────────────────────────────────────── def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, proving: str = "delegate", - mode: str | None = None, record: str | None = None, merkle_proof: str | None = None, + mode: str | None = None, record: str | None = None, compliance_proofs: Any = None, merkle_proof: str | None = None, gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0) -> Progress: """Commit funds on the source chain and return the transfer's ``Progress``. @@ -566,8 +566,8 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin a ``Checkpoint`` at each boundary (after each approval hash; after proving and BEFORE broadcast for Aleo legs; after broadcast). Aleo legs prove with ``proving="delegate"`` (DPS) or ``"local"``; ``mode`` is ``"caller"|"signer"`` for Aleo Hyperlane and ``"private"|"public"| - "public-as-signer"`` for Aleo xReserve burns (``record`` / ``merkle_proof`` feed a private - burn). The Hyperlane hook payment is re-quoted right before proving unless + "public-as-signer"`` for Aleo xReserve burns (``record`` / ``compliance_proofs`` feed a private + burn; ``merkle_proof`` is the earlier name for one literal bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` pins it. ``secret_nonce`` is the private-mint commitment secret for an EVM xReserve deposit — required when ``plan.mint_mode == "private"``; keep it, ``complete`` needs it again and the SDK never stores it. @@ -654,7 +654,8 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin burn = bridge.xreserve._burn fee_options["withdrawal_fee_atomic"] = fee_atomic call = burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, - record=record, merkle_proof=merkle_proof, route=resolved.route, **fee_options) + record=record, compliance_proofs=compliance_proofs, merkle_proof=merkle_proof, + route=resolved.route, **fee_options) receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state=verification) emit.finalize() return to_progress(plan, receipt) diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index de6d30b8..ddc6e610 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -5,8 +5,11 @@ import re from typing import TYPE_CHECKING, Any +from aleo.compliance import ProofBundle + from ._calls import AleoCall from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from .freezelist import resolve_one_bundle from .registry import Asset from .types import PrivacyReceipt from .units import format_decimal_amount, resolve_amount @@ -104,23 +107,32 @@ def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: return self._bridge._call(privacy.program, function, inputs, build) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: - """Private record → public balance. The record defaults to the smallest covering one; ARC-22 also needs the - freeze-list exclusion proof (computed for the signer; veil's empty pair when the list is empty).""" + compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + """Move a private record into the public balance. + + The record defaults to the smallest one covering the amount. An ARC-22 + token (USDCx) also checks that the signer is not on its freeze list: + leave ``compliance_proofs`` unset and the SDK computes the exclusion + proof from the published list (cached until the list changes), or + pass one ``[MerkleProof; 2]`` bundle to use it as given. ``merkle_proof`` + is the earlier name for that literal and still works. + """ resolved = self._asset(asset, "unshielding") atomic, literal = self._amount(resolved, amount, amount_atomic, "Unshielding") privacy = resolved.privacy assert privacy is not None record = record if record is not None else self.select_record(privacy.program, atomic) + proof = resolve_one_bundle(compliance_proofs, merkle_proof) if privacy.kind == "arc22": - proof = merkle_proof if merkle_proof is not None else \ - self._bridge.freezelist.exclusion_proof(self._bridge.aleo_address(), privacy.program) + if proof is None: + proof = self._bridge.freezelist.exclusion_proof(self._bridge.aleo_address(), privacy.program) function, inputs = "transfer_private_to_public", [self._recipient(resolved, recipient), literal, record, proof] else: if recipient is not None and recipient != self._bridge.aleo_address(): raise ConfigurationError("ARC-20 unshield always credits the caller; omit recipient=") - if merkle_proof is not None: - raise ConfigurationError("ARC-20 unshield takes no Merkle proof; omit merkle_proof=") + if proof is not None: + raise ConfigurationError("ARC-20 unshield takes no compliance proof; omit compliance_proofs=") function, inputs = "unshield", [record, literal] human = format_decimal_amount(atomic, resolved.decimals) diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index f3f4e15b..7659b3b5 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -6,11 +6,13 @@ from typing import TYPE_CHECKING, Any import requests +from aleo.compliance import ProofBundle from . import encoding as enc from ._calls import AleoCall from ._keccak import keccak256 from .circle import CircleClient +from .freezelist import resolve_one_bundle from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Route @@ -136,22 +138,34 @@ def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, reci return route.meta_str("bridgeProgram"), function, [amount_lit, domain_lit, recipient_lit] def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", - record: str | None = None, merkle_proof: str | None = None, route: Route | None = None) -> AleoCall[BurnReceipt]: - """Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a - freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than - the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle.""" + record: str | None = None, compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, + merkle_proof: str | None = None, route: Route | None = None) -> AleoCall[BurnReceipt]: + """Burn USDCx for USDC on the selected EVM route (Ethereum by default). + + ``mode="private"`` spends a USDCx Token record through the wrapper and + proves the signer is not on the USDCx freeze list. Both inputs are + resolved from chain state when not supplied: the record is the + smallest one covering the amount, and the exclusion proof is computed + from the published list and cached until the list changes. Pass one + ``[MerkleProof; 2]`` bundle in ``compliance_proofs`` to use it as given + (``merkle_proof`` is the earlier name for the literal form). The amount + must exceed the 2 USDCx withdrawal fee. The Aleo burn-attestation + service forwards accepted burns to Circle. + """ return self._burn(recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, - record=record, merkle_proof=merkle_proof, route=route) + record=record, compliance_proofs=compliance_proofs, merkle_proof=merkle_proof, route=route) def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, - mode: str = "private", record: str | None = None, merkle_proof: str | None = None, + mode: str = "private", record: str | None = None, + compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, route: Route | None = None, withdrawal_fee_atomic: int | None = None) -> AleoCall[BurnReceipt]: """Build a burn with the lifecycle's validated execution-time estimate, if supplied.""" if mode not in BURN_MODES: raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + merkle_proof = resolve_one_bundle(compliance_proofs, merkle_proof) if mode != "private" and (record is not None or merkle_proof is not None): raise ConfigurationError( - f"mode={mode!r} burns the public balance; record=/merkle_proof= only apply to mode='private'") + f"mode={mode!r} burns the public balance; record=/compliance_proofs= only apply to mode='private'") route = self._validated(self._bridge.registry.route(route.id) if route is not None else self.outbound_route(), direction="burn") source = self._bridge.registry.asset(route.source_asset_id) atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) @@ -172,7 +186,7 @@ def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None freezelist = getattr(self._bridge, "freezelist", None) if freezelist is None: raise ConfigurationError( - "burn(mode='private') needs merkle_proof; pass merkle_proof= explicitly " + "burn(mode='private') needs a compliance proof; pass compliance_proofs= explicitly " "(bridge.freezelist is not available yet)") merkle_proof = freezelist.exclusion_proof(self._bridge.aleo_address(), token_program) program, function, inputs = self.build_burn_inputs(route, mode=mode, amount_atomic=atomic, recipient=recipient, diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index a5b05bcb..25215f66 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -158,6 +158,19 @@ def wait_for_transaction(self, tx_id: str, *, timeout: float = 45.0, poll_interv def get_transaction_object(self, tx_id: str) -> FakeTx: return FakeTx(tx_id, "hyp_warp_token_wbtc_v2.aleo", "transfer_remote", [{"value": "99field"}]) + def get_program_mapping_value(self, program_id: str, mapping_name: str, key: str) -> Any: + """The node's mapping endpoint (no program handle): the same mappings dict, program → mapping → key.""" + self._aleo.mapping_reads.append((program_id, mapping_name, key)) + return self._aleo.mappings.get(program_id, {}).get(mapping_name, {}).get(key) + + def get_program_freeze_list(self, program_id: str) -> list[int] | None: + """The compliance endpoint: script with ``FakeAleo.freeze_list_trees[program] = nodes`` (None = no list).""" + self._aleo.tree_reads.append(program_id) + tree = self._aleo.freeze_list_trees.get(program_id) + if isinstance(tree, Exception): + raise tree + return tree + def get_confirmed_transaction(self, tx_id: str) -> Any: """Script via ``FakeAleo.confirmed_transactions[tx_id] = ``; else raises TransactionNotFound (mirrors the real facade's 404 mapping) — additive for lifecycle recovery/status tests.""" @@ -202,6 +215,9 @@ def __init__(self, mappings: dict | None = None, records: list[dict] | None = No self.fetched: list = [] self.registered: list = [] self.record_queries: list = [] + self.freeze_list_trees: dict[str, Any] = {} # freeze-list program -> published tree nodes (or an exception) + self.mapping_reads: list = [] + self.tree_reads: list = [] self.confirmed_transactions: dict[str, Any] = {} # tx id -> confirmed-transaction JSON (script for get_confirmed_transaction) self.confirmed_transaction_queries: list = [] self.duplicate_on_submit = False diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 86974d7c..b50e0517 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -190,9 +190,9 @@ def _route(self): else "xreserve:aleo-testnet/usdcx->sepolia/usdc") def burn(self, recipient, *, amount=None, amount_atomic=None, mode="private", - record=None, merkle_proof=None, route=None) -> FakeAleoCall: + record=None, compliance_proofs=None, merkle_proof=None, route=None) -> FakeAleoCall: kw = dict(recipient=recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, - record=record, merkle_proof=merkle_proof) + record=record, compliance_proofs=compliance_proofs, merkle_proof=merkle_proof) self.fake.calls.append(("xreserve.burn", kw)) route_id = route.id if route is not None else self._route() program = "shielded_usdcx_wrapper.aleo" if mode == "private" else "usdcx_bridge_v2.aleo" diff --git a/bridge-sdk/tests/live/test_compliance_live.py b/bridge-sdk/tests/live/test_compliance_live.py new file mode 100644 index 00000000..bea54239 --- /dev/null +++ b/bridge-sdk/tests/live/test_compliance_live.py @@ -0,0 +1,73 @@ +"""Freeze-list proofs against the REAL mainnet: the USDCx list's root and published tree, the +SDK's automatic resolution, and — when the operator's shell enables it — a USDCx unshield that +submits a transaction with a proof the SDK built. The private xReserve burn through ``execute`` +is covered by ``test_lifecycle_live.py::test_aleo_xreserve`` (the same resolver fills its proof). + +Read tier: ``BRIDGE_LIVE_READS=1`` (+ ``BRIDGE_PRIVATE_KEY``). +Funded tier: the mainnet gates of ``config.py`` with the case ``aleo-usdcx-unshield`` listed, plus +``BRIDGE_LIVE_ALEO_SCANNER=1`` (record selection shares the view key with the hosted scanner). +""" +from __future__ import annotations + +import os + +import pytest + +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, exclusion_proof_from_tree + +from . import config as live_config, helpers as live_helpers + +pytestmark = pytest.mark.live + +TOKEN = "usdcx_stablecoin.aleo" +LIST = "usdcx_freezelist.aleo" +CASE = "aleo-usdcx-unshield" +UNSHIELD_AMOUNT = "0.1" + + +def test_published_tree_matches_the_on_chain_root(live_bridge): + tree = live_bridge.aleo.network.get_program_freeze_list(LIST) + assert tree is not None and len(tree) >= 3 + root = live_bridge.freezelist._proofs.root(LIST) + assert tree[-1] == root + proof = exclusion_proof_from_tree(tree, live_bridge.aleo_address(), root, "mainnet") + assert proof.count("leaf_index") == 2 + if root == EMPTY_TREE_ROOT: + assert proof == EMPTY_MERKLE_PROOF_PAIR + assert live_bridge.aleo.network.get_program_freeze_list("credits.aleo") is None + + +def test_exclusion_proof_resolves_through_the_token_program_and_caches(live_bridge): + fl = live_bridge.freezelist + fl.clear_cache() + assert fl.freeze_list_program(TOKEN) == LIST + first = fl.exclusion_proof(live_bridge.aleo_address(), TOKEN) + assert first == fl.exclusion_proof(live_bridge.aleo_address(), LIST) + entry = fl._proofs._cache.current("mainnet", LIST, fl._proofs.root(LIST)) + assert entry is not None and entry.tree[-1] == entry.root + # The mapping walk agrees with the published tree on the root. + assert fl.tree(TOKEN)[-1] == entry.root + + +def test_usdcx_unshield_with_an_sdk_built_proof(record_property): + if not live_config.mainnet_case_enabled(CASE): + pytest.skip(f"enable with the mainnet gates and {live_config.MAINNET_CASES_VAR}={CASE}") + if live_config.value("BRIDGE_LIVE_ALEO_SCANNER") != "1": + pytest.skip("record selection needs the hosted scanner; set BRIDGE_LIVE_ALEO_SCANNER=1 to share the view key") + bridge = live_helpers.build_bridge("mainnet") + bridge.aleo.records.register(bridge.aleo.default_account) + call = bridge.unshield("aleo/usdcx", amount=UNSHIELD_AMOUNT) + assert call.function_name == "transfer_private_to_public" + proof = call.inputs[-1] + assert proof == bridge.freezelist.exclusion_proof(bridge.aleo_address(), TOKEN) + record_property("proof", proof[:80]) + if not live_config.mainnet_execution_enabled(): + print(f" rehearsal {UNSHIELD_AMOUNT} USDCx unshield prepared with proof {proof[:40]}…; not submitted") + return + before = bridge.mapping_value(TOKEN, "balances", bridge.aleo_address()) + receipt = call.delegate() + record_property("transaction_id", receipt.transaction_id) + print(f" submitted {receipt.transaction_id}") + after = bridge.mapping_value(TOKEN, "balances", bridge.aleo_address()) + print(f" balance public USDCx {before} → {after}") + assert receipt.transaction_id.startswith("at1") diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py index 14ffeed0..2ae5a560 100644 --- a/bridge-sdk/tests/test_client_lifecycle.py +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -79,7 +79,8 @@ def test_execute_wait_get_status_recover_resume_complete_forward(monkeypatch): Bridge.execute(b, "PLAN", on_checkpoint=cb, proving="local", mode="signer", record="r", merkle_proof="m", gas_payment_microcredits=5, secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) assert seen["args"] == ("PLAN",) and seen["kwargs"] == dict( - on_checkpoint=cb, proving="local", mode="signer", record="r", merkle_proof="m", gas_payment_microcredits=5, + on_checkpoint=cb, proving="local", mode="signer", record="r", compliance_proofs=None, merkle_proof="m", + gas_payment_microcredits=5, secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) seen = _spy(monkeypatch, "wait") on_err = lambda exc: None diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py index 8df793c1..3f6c1891 100644 --- a/bridge-sdk/tests/test_freezelist.py +++ b/bridge-sdk/tests/test_freezelist.py @@ -2,6 +2,7 @@ from aleo_bridge import freezelist as fl from aleo_bridge.errors import ConfigurationError +from aleo.compliance import FreezeListError # Vectors from sdk/src/integrations/sealance/merkle-tree.ts docstrings A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" @@ -46,7 +47,7 @@ def test_generate_leaves_sorts_pads_and_filters_zero(): assert fl.generate_leaves([ZERO, ZERO, A]) == ["0field", f"{A_FIELD}field"] assert fl.generate_leaves([]) == ["0field", "0field"] assert len(fl.generate_leaves([A] * 5)) == 8 and fl.generate_leaves([A] * 5)[:3] == ["0field"] * 3 - with pytest.raises(ConfigurationError, match="Leaves limit exceeded"): + with pytest.raises(FreezeListError, match="Leaves limit exceeded"): fl.generate_leaves([A] * (2 ** 14 + 1), 15) @@ -57,9 +58,9 @@ def test_build_tree_shapes_and_errors(): assert len(four) == 7 and four[:4] == [1, 2, 3, 4] assert f"{four[4]}field" == fl.hash_two("1field", "1field", "2field", "mainnet") # leaf level uses 1field assert f"{four[6]}field" == fl.hash_two("0field", f"{four[4]}field", f"{four[5]}field", "mainnet") # inner level uses 0field - with pytest.raises(ConfigurationError, match="cannot be empty"): + with pytest.raises(FreezeListError, match="cannot be empty"): fl.build_tree([], "mainnet") - with pytest.raises(ConfigurationError, match="even number"): + with pytest.raises(FreezeListError, match="even number"): fl.build_tree(["1field", "2field", "3field"], "mainnet") @@ -71,8 +72,10 @@ def test_leaf_indices_and_sibling_path(): path = fl.sibling_path(four, 1, 15) assert len(path) == 15 and path[:3] == [2, 1, four[5]] and path[3:] == [0] * 12 # leaf, sibling, uncle, zero-padding assert len(fl.sibling_path(four, 1, 16)) == 16 - assert fl.format_merkle_proof(([2, 1], 1), ([3, 4], 2)) == \ - "[{ siblings: [2field, 1field], leaf_index: 1u32 }, { siblings: [3field, 4field], leaf_index: 2u32 }]" + pad = [0] * 14 + assert fl.format_merkle_proof(([2, 1] + pad, 1), ([3, 4] + pad, 2)) == ( + "[{ siblings: [2field, 1field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }, " + "{ siblings: [3field, 4field, " + ", ".join(["0field"] * 14) + "], leaf_index: 2u32 }]") def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): @@ -87,23 +90,46 @@ def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): def test_freezelist_reads_mappings_and_builds_proof(bridge): mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) assert bridge.freezelist.leaves(PROGRAM) == [] - # An unreadable on-chain root is fatal now (item 4): no silent fallback to an unverified proof. + # An unreadable on-chain root is fatal: no silent fallback to an unverified proof. with pytest.raises(ConfigurationError, match="unreadable"): bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) mappings["freeze_list_root"] = {"1u8": f"{fl.EMPTY_TREE_ROOT}field"} assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR + assert bridge.aleo.tree_reads == [] # an empty list never fetches the tree mappings["freeze_list_last_index"] = {"true": "1u32"} mappings["freeze_list_index"] = {"0u32": A, "1u32": ZERO} assert bridge.freezelist.leaves(PROGRAM) == [A] # zero address filtered - # The root moved once the list gained a member; seed the fake with the matching root. - mappings["freeze_list_root"] = {"1u8": f"{fl.build_tree(fl.generate_leaves([A]), 'mainnet')[-1]}field"} + # The root moved once the list gained a member; seed the fake with the matching root and published tree. + tree = fl.build_tree(fl.generate_leaves([A]), "mainnet") + mappings["freeze_list_root"] = {"1u8": f"{tree[-1]}field"} + bridge.aleo.freeze_list_trees[FREEZE_LIST_PROGRAM] = tree assert bridge.freezelist.tree(PROGRAM)[:2] == [0, A_FIELD] one = "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == f"[{one}, {one}]" + assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == f"[{one}, {one}]" + assert bridge.aleo.tree_reads == [FREEZE_LIST_PROGRAM] # cached while the root is unchanged with pytest.raises(ConfigurationError, match="freeze list"): bridge.freezelist.exclusion_proof(A, PROGRAM) +def test_exclusion_proof_refetches_when_the_root_changes_and_rejects_a_stale_tree(bridge): + mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) + tree = fl.build_tree(fl.generate_leaves([A]), "mainnet") + mappings["freeze_list_root"] = {"1u8": f"{tree[-1]}field"} + bridge.aleo.freeze_list_trees[FREEZE_LIST_PROGRAM] = tree + first = bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + bigger = fl.build_tree(fl.generate_leaves([A, B]), "mainnet") + mappings["freeze_list_root"] = {"1u8": f"{bigger[-1]}field"} + with pytest.raises(ConfigurationError, match="does not match the on-chain root"): # indexer still serves the old tree + bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + bridge.aleo.freeze_list_trees[FREEZE_LIST_PROGRAM] = bigger + second = bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + assert second != first and bridge.aleo.tree_reads == [FREEZE_LIST_PROGRAM] * 3 + bridge.freezelist.clear_cache() + assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == second + assert bridge.aleo.tree_reads == [FREEZE_LIST_PROGRAM] * 4 + + def test_exclusion_proof_raises_when_root_unreadable(bridge): # No usdcx_freezelist.aleo/freeze_list_root mapping at all — must raise, never fall back. with pytest.raises(ConfigurationError, match=r"freeze_list_root\[1u8\] is unreadable"): @@ -134,6 +160,8 @@ def test_freeze_list_program_resolves_via_fallback_table_when_imports_unavailabl def test_freeze_list_program_raises_for_unknown_program(bridge): with pytest.raises(ConfigurationError, match="no freeze-list program"): bridge.freezelist.freeze_list_program("arc20_wbtc.aleo") + with pytest.raises(ConfigurationError, match="compliance_proofs"): + bridge.freezelist.exclusion_proof(RECIPIENT, "arc20_wbtc.aleo") def test_freeze_list_program_propagates_unrelated_exceptions(bridge, monkeypatch): @@ -160,7 +188,55 @@ def test_leaves_reads_from_freeze_list_program_not_token_program(bridge): def test_exclusion_proof_verifies_onchain_root(bridge): fl_mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) fl_mappings["freeze_list_root"] = {"1u8": "123field"} - with pytest.raises(ConfigurationError, match="freeze-list root"): + bridge.aleo.freeze_list_trees[FREEZE_LIST_PROGRAM] = [0, 0, fl.EMPTY_TREE_ROOT] + with pytest.raises(ConfigurationError, match="freeze-list tree root"): bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) fl_mappings["freeze_list_root"] = {"1u8": f"{fl.EMPTY_TREE_ROOT}field"} assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR + fl_mappings["freeze_list_root"] = {"1u8": "123field"} + with pytest.raises(ConfigurationError, match="freeze-list root"): # the mapping-walk tree checks the root too + bridge.freezelist.tree(PROGRAM) + + +# ── compliance_proofs= and the merkle_proof= alias ────────────────────────── + +ONE_LITERAL = fl.EMPTY_MERKLE_PROOF_PAIR + + +def _seed_empty_list(bridge): + bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {})["freeze_list_root"] = {"1u8": f"{fl.EMPTY_TREE_ROOT}field"} + + +def test_unshield_accepts_every_proof_form_and_computes_when_unset(bridge): + _seed_empty_list(bridge) + pair = fl.MerkleProofPair(fl.MerkleProof((0,) * 16, 1), fl.MerkleProof((0,) * 16, 1)) + for kwargs in ({}, {"compliance_proofs": ONE_LITERAL}, {"compliance_proofs": (ONE_LITERAL,)}, + {"compliance_proofs": pair}, {"compliance_proofs": (pair.left, pair.right)}, + {"merkle_proof": ONE_LITERAL}): + call = bridge.unshield("aleo/usdcx", amount="1", **kwargs) + assert call.inputs[-1] == ONE_LITERAL, kwargs + assert bridge.aleo.mapping_reads.count((FREEZE_LIST_PROGRAM, "freeze_list_root", "1u8")) == 1 # only the unset case read the chain + + +def test_unshield_rejects_both_proof_arguments_and_bad_values(bridge): + _seed_empty_list(bridge) + with pytest.raises(ConfigurationError, match="not both"): + bridge.unshield("aleo/usdcx", amount="1", compliance_proofs=ONE_LITERAL, merkle_proof=ONE_LITERAL) + with pytest.raises(ConfigurationError, match="exactly one"): + bridge.unshield("aleo/usdcx", amount="1", compliance_proofs=(ONE_LITERAL, ONE_LITERAL)) + with pytest.raises(ConfigurationError, match="encoded"): + bridge.unshield("aleo/usdcx", amount="1", compliance_proofs="nope") + with pytest.raises(ConfigurationError, match="takes no compliance proof"): + bridge.unshield("aleo/wbtc", amount="0.001", record="{ owner: aleo1x.private }", compliance_proofs=ONE_LITERAL) + + +def test_private_burn_accepts_every_proof_form(bridge): + _seed_empty_list(bridge) + recipient = "0x" + "11" * 20 + for kwargs in ({}, {"compliance_proofs": ONE_LITERAL}, {"compliance_proofs": (ONE_LITERAL,)}, {"merkle_proof": ONE_LITERAL}): + call = bridge.xreserve.burn(recipient, amount="3", **kwargs) + assert call.function_name == "private_burn" and call.inputs[-1] == ONE_LITERAL, kwargs + with pytest.raises(ConfigurationError, match="not both"): + bridge.xreserve.burn(recipient, amount="3", compliance_proofs=ONE_LITERAL, merkle_proof=ONE_LITERAL) + with pytest.raises(ConfigurationError, match="only apply to mode='private'"): + bridge.xreserve.burn(recipient, amount="3", mode="public", compliance_proofs=ONE_LITERAL) diff --git a/bridge-sdk/tests/test_privacy.py b/bridge-sdk/tests/test_privacy.py index 66cdd767..560e4ba6 100644 --- a/bridge-sdk/tests/test_privacy.py +++ b/bridge-sdk/tests/test_privacy.py @@ -43,7 +43,7 @@ def test_unshield_arc20_selects_smallest_covering_record(bridge): assert bridge.aleo.record_queries[-1] == {"program": "arc20_sol.aleo", "record": "Token", "unspent": True} result = call.delegate(wait=False) assert result == PrivacyReceipt("at1delegated", "aleo/sol", "0.25", 250_000_000, "unshield") - with pytest.raises(ConfigurationError, match="ARC-20 unshield takes no Merkle proof"): + with pytest.raises(ConfigurationError, match="ARC-20 unshield takes no compliance proof"): bridge.privacy.unshield("aleo/sol", amount="0.25", merkle_proof="[x]") diff --git a/bridge-sdk/tests/test_xreserve.py b/bridge-sdk/tests/test_xreserve.py index 5c699674..1bd3622a 100644 --- a/bridge-sdk/tests/test_xreserve.py +++ b/bridge-sdk/tests/test_xreserve.py @@ -79,9 +79,9 @@ def test_burn_validates_mode_before_any_amount_or_chain_work(bridge): def test_burn_rejects_record_or_merkle_proof_for_public_modes(bridge): - with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + with pytest.raises(ConfigurationError, match="record=/compliance_proofs="): bridge.xreserve.burn(EVM1, amount="2.5", mode="public", record=USDCX_RECORD) - with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + with pytest.raises(ConfigurationError, match="record=/compliance_proofs="): bridge.xreserve.burn(EVM1, amount="2.5", mode="public-as-signer", merkle_proof="[x]") assert bridge.aleo.record_queries == [] diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index df109e5e..f2c780d7 100644 --- a/sdk/python/aleo/__init__.py +++ b/sdk/python/aleo/__init__.py @@ -30,6 +30,16 @@ from .facade import Aleo as Aleo from .facade import AsyncAleo as AsyncAleo from .facade import HTTPProvider as HTTPProvider +from .compliance import ( + AsyncFreezeListProofs as AsyncFreezeListProofs, + FreezeListError as FreezeListError, + FreezeListProofs as FreezeListProofs, + FrozenAddressError as FrozenAddressError, + MerkleProof as MerkleProof, + MerkleProofPair as MerkleProofPair, + ProofBundle as ProofBundle, + ProofSlot as ProofSlot, +) from .facade.errors import ( AleoError as AleoError, TransactionNotFound as TransactionNotFound, diff --git a/sdk/python/aleo/_client_common.py b/sdk/python/aleo/_client_common.py index 254b2efa..44c338fa 100644 --- a/sdk/python/aleo/_client_common.py +++ b/sdk/python/aleo/_client_common.py @@ -3,7 +3,7 @@ import random import time -from typing import Any, Callable, TypeVar +from typing import Any, Callable, TypeVar, cast from urllib.parse import urlparse _T = TypeVar("_T") @@ -214,6 +214,28 @@ def jwt_expired(jwt_data: dict[str, Any]) -> bool: return now_ms() >= exp - FIVE_MINUTES_MS +def parse_freeze_list_tree(raw: Any) -> list[int] | None: + """Turn the freeze-list endpoint's body into tree node integers. + + The endpoint publishes decimal node strings, leaves first and root last; + ``null`` (or a missing body) means the program has no freeze list. + + Raises: + AleoNetworkError: If the body is not a list of decimal strings. + """ + if raw is None: + return None + if not isinstance(raw, list): + raise AleoNetworkError("Invalid compliance tree: expected a list of decimal node values") + nodes: list[int] = [] + for node in cast(list[Any], raw): + text = str(node) + if not text.isascii() or not text.isdigit(): + raise AleoNetworkError(f"Invalid compliance tree node {node!r}: expected a decimal field value") + nodes.append(int(text)) + return nodes + + def validate_block_range(start: int, end: int) -> None: """Reject a block range the node would refuse, before sending it. diff --git a/sdk/python/aleo/async_network_client.py b/sdk/python/aleo/async_network_client.py index b80e2881..4c0b7b8b 100644 --- a/sdk/python/aleo/async_network_client.py +++ b/sdk/python/aleo/async_network_client.py @@ -33,6 +33,7 @@ strip_quotes, validate_block_range, async_retry_with_backoff, + parse_freeze_list_tree, ) from .security import encrypt_proving_request @@ -684,6 +685,25 @@ async def get_program_mapping_plaintext( import json as _json return Plaintext.from_string(_json.loads(raw)) + async def get_program_freeze_list(self, program_id: str) -> list[int] | None: + """Read the published compliance Merkle tree of a freeze-list program (awaited). + + Same contract as the sync client: node values leaves first and root + last, ``None`` when the program has no freeze list. Compare the last + node with the on-chain ``freeze_list_root`` before building a witness. + + Raises: + AleoNetworkError: If the request fails for another reason, or the + response is not a list of decimal node values. + """ + try: + raw = await self._get(f"/programs/{program_id}/compliance/freeze-list", "getProgramFreezeList") + except AleoNetworkError as exc: + if exc.status == 404: + return None + raise + return parse_freeze_list_tree(raw) + async def get_transaction_object(self, tx_id: str) -> Any: """Fetch a transaction and parse it into a ``Transaction``. diff --git a/sdk/python/aleo/compliance.py b/sdk/python/aleo/compliance.py new file mode 100644 index 00000000..d9dc9909 --- /dev/null +++ b/sdk/python/aleo/compliance.py @@ -0,0 +1,666 @@ +"""Freeze-list exclusion proofs for ARC-22 compliant programs. + +Programs that enforce a freeze list (the USDCx stablecoin, Shield Swap) take +a ``[MerkleProof; 2]`` argument proving that an address is *not* on the list. +This module builds those arguments and caches them per client: + +- :func:`exclusion_proof_from_tree` turns a published tree into the proof + literal for one address and checks every witness against the root. +- :class:`FreezeListProofs` / :class:`AsyncFreezeListProofs` read the current + root, fetch the tree only when the root changed, and reuse witnesses while + the root is unchanged. While a list is empty the fixed empty-tree pair is + returned without fetching anything beyond the root. +- :func:`freeze_list_program` finds the freeze-list program a token program + checks, so callers can name the token and let the SDK find the list. + +Tree layout (shared with the TypeScript SDK and the compliance API): leaves +are frozen addresses as little-endian field integers, sorted ascending and +front-padded with ``0field`` to a power of two (minimum two). The leaf level +hashes ``Poseidon4([1field, l, r])``, inner levels ``Poseidon4([0field, l, r])``. +A sibling path starts with the leaf itself, then one sibling per level, +zero-padded to 16 entries. +""" +from __future__ import annotations + +import math +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Sequence, cast + +from ._client_common import AleoError, AleoNetworkError + +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +DEFAULT_DEPTH = 15 # tree capacity 2**(depth-1) leaves +PROOF_SIBLINGS = 16 # struct MerkleProof { siblings: [field; 16u32], leaf_index: u32 } +FREEZE_LIST_MAPPING = "freeze_list" # address => bool (frozen flag) +FREEZE_LIST_LAST_INDEX_MAPPING = "freeze_list_last_index" # bool => u32, keyed "true" +FREEZE_LIST_INDEX_MAPPING = "freeze_list_index" # u32 => address ([0u32] = zero-address sentinel) +FREEZE_LIST_ROOT_MAPPING = "freeze_list_root" # u8 => field, "1u8" current / "2u8" previous +CURRENT_ROOT_KEY = "1u8" +EMPTY_TREE_ROOT = 3642222252059314292809609689035560016959342421640560347114299934615987159853 +EMPTY_MERKLE_PROOF = "{ siblings: [" + ", ".join(["0field"] * PROOF_SIBLINGS) + "], leaf_index: 1u32 }" +EMPTY_MERKLE_PROOF_PAIR = f"[{EMPTY_MERKLE_PROOF}, {EMPTY_MERKLE_PROOF}]" +FREEZELIST_SUFFIX = "freezelist.aleo" + +#: Token programs whose freeze list is known without reading their imports. +#: ``None`` marks a program that takes the proof argument and ignores it. +KNOWN_FREEZE_LISTS: dict[str, str | None] = { + "credits.aleo": None, + "usdcx_stablecoin.aleo": "usdcx_freezelist.aleo", + "test_usdcx_stablecoin.aleo": "test_usdcx_freezelist.aleo", +} + +MAX_CACHED_LISTS = 16 +MAX_CACHED_ADDRESSES = 256 + + +class FreezeListError(AleoError): + """A freeze-list proof could not be resolved; the message names what to pass instead.""" + + +class FrozenAddressError(FreezeListError): + """The address is on the freeze list, so no exclusion proof exists for it.""" + + +# ── Tree math ──────────────────────────────────────────────────────────────── + +def address_to_field_int(address: str) -> int: + """The field element a Leo program sees for an address: its bech32m payload read little-endian.""" + from .mainnet import Address + try: + return int.from_bytes(bytes(Address.from_string(address).to_bytes_le()), "little") + except Exception as exc: + raise FreezeListError(f"Not an Aleo address: {address!r}") from exc + + +def hash_two(prefix: str, left: str, right: str, network: str) -> str: + """``Poseidon4(Plaintext("[prefix,left,right]").to_fields())`` as a ``…field`` literal.""" + net = _network_module(network) + plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]") + return str(net.Poseidon4().hash(plaintext.to_fields())) + + +def generate_leaves(addresses: Iterable[str], depth: int = DEFAULT_DEPTH) -> list[str]: + """Frozen addresses as sorted ``…field`` leaves: zero address dropped, front-padded to a power of two (minimum two).""" + live = [a for a in addresses if a != ZERO_ADDRESS] + max_leaves = 2 ** (depth - 1) + if len(live) > max_leaves: + raise FreezeListError(f"Leaves limit exceeded. Max: {max_leaves}, provided: {len(live)}") + count = 2 if len(live) <= 1 else 2 ** math.ceil(math.log2(len(live))) + fields = sorted(address_to_field_int(a) for a in live) + return ["0field"] * (count - len(fields)) + [f"{f}field" for f in fields] + + +def build_tree(leaves: Sequence[str], network: str) -> list[int]: + """The bottom-up tree as integers: leaves first, root last (the layout the compliance API publishes).""" + if not leaves: + raise FreezeListError("Leaves array cannot be empty") + if len(leaves) % 2: + raise FreezeListError("Leaves array must have even number of elements") + tree = list(leaves) + level = list(leaves) + while len(level) > 1: + prefix = "1field" if len(level) == len(leaves) else "0field" + level = [hash_two(prefix, level[i], level[i + 1], network) for i in range(0, len(level), 2)] + tree.extend(level) + return [int(node[: -len("field")]) for node in tree] + + +def leaf_indices(tree: Sequence[int], address: str) -> tuple[int, int]: + """The (left, right) leaf indices bracketing *address*: the two leaves a non-inclusion proof opens.""" + count = (len(tree) + 1) // 2 + target = address_to_field_int(address) + leaves = tree[:count] + right = next((i for i, leaf in enumerate(leaves) if target <= leaf), -1) + left = right - 1 + if right == -1: + right = left = count - 1 + if right == 0: + left = 0 + return left, right + + +def sibling_path(tree: Sequence[int], index: int, depth: int = DEFAULT_DEPTH) -> list[int]: + """The leaf at *index*, then its sibling at each level, zero-padded to *depth* entries.""" + count = (len(tree) + 1) // 2 + path = [tree[index]] + node, parent, level = index, count, 1 + while parent < len(tree): + sibling = node + 1 if node % 2 == 0 else node - 1 + path.append(tree[sibling]) + node = parent + index // 2 ** level + parent += count // 2 ** level + level += 1 + while len(path) < depth: + path.append(0) + return path + + +def format_merkle_proof(left: tuple[Sequence[int], int], right: tuple[Sequence[int], int]) -> str: + """A ``[MerkleProof; 2]`` literal from two (siblings, leaf_index) pairs.""" + return str(MerkleProofPair(MerkleProof(tuple(left[0]), left[1]), MerkleProof(tuple(right[0]), right[1]))) + + +# ── Proof values ───────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class MerkleProof: + """One Merkle path of an exclusion proof, as the contracts take it. + + Attributes: + siblings: The leaf followed by one sibling per level, 16 field values + in all; unused levels are zero. + leaf_index: The leaf's position in the tree, a ``u32``. + """ + + siblings: tuple[int, ...] + leaf_index: int + + def __post_init__(self) -> None: + if len(self.siblings) != PROOF_SIBLINGS: + raise FreezeListError(f"A MerkleProof needs {PROOF_SIBLINGS} siblings, got {len(self.siblings)}") + if self.leaf_index < 0 or any(s < 0 for s in self.siblings): + raise FreezeListError("MerkleProof values must be non-negative") + + def __str__(self) -> str: + return "{ siblings: [" + ", ".join(f"{s}field" for s in self.siblings) + f"], leaf_index: {self.leaf_index}u32 }}" + + @classmethod + def from_string(cls, text: str) -> "MerkleProof": + """Parse one ``{ siblings: [..], leaf_index: Nu32 }`` literal.""" + body = text.strip() + if not (body.startswith("{") and body.endswith("}")): + raise FreezeListError(f"Not a MerkleProof literal: {text[:40]!r}") + try: + inner = body[1:-1] + start, end = inner.index("["), inner.index("]") + siblings = tuple(int(s.strip().removesuffix("field")) for s in inner[start + 1:end].split(",")) + index_text = inner[end + 1:].split("leaf_index:", 1)[1].strip().rstrip(",").strip() + leaf_index = int(index_text.removesuffix("u32")) + except (ValueError, IndexError) as exc: + raise FreezeListError(f"Not a MerkleProof literal: {text[:40]!r}") from exc + return cls(siblings, leaf_index) + + +@dataclass(frozen=True) +class MerkleProofPair: + """The two paths that together prove one address is absent from one list. + + Attributes: + left: The path to the leaf just below the address (or the repeated boundary leaf). + right: The path to the leaf just above the address. + """ + + left: MerkleProof + right: MerkleProof + + def __str__(self) -> str: + return f"[{self.left}, {self.right}]" + + @classmethod + def from_string(cls, text: str) -> "MerkleProofPair": + """Parse a ``[MerkleProof; 2]`` literal.""" + body = text.strip() + if not (body.startswith("[") and body.endswith("]")): + raise FreezeListError(f"Not a [MerkleProof; 2] literal: {text[:40]!r}") + parts: list[str] = [] + depth = 0 + current: list[str] = [] + for ch in body[1:-1]: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if ch == "," and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + if len(parts) != 2: + raise FreezeListError(f"A [MerkleProof; 2] literal holds two proofs, found {len(parts)}") + return cls(MerkleProof.from_string(parts[0]), MerkleProof.from_string(parts[1])) + + +#: One compliance proof argument as a caller may supply it: the ``[MerkleProof; 2]`` +#: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProof` paths. +ProofBundle = str | MerkleProofPair | tuple[MerkleProof, MerkleProof] + + +def bundle_literal(bundle: ProofBundle) -> str: + """The ``[MerkleProof; 2]`` literal for a caller-supplied proof, validated.""" + if isinstance(bundle, str): + text = bundle.strip() + if not (text.startswith("[") and text.endswith("]")): + raise FreezeListError("A compliance proof must be an encoded [MerkleProof; 2] Aleo literal") + return text + if isinstance(bundle, MerkleProofPair): + return str(bundle) + if isinstance(bundle, tuple) and len(bundle) == 2 and all(isinstance(p, MerkleProof) for p in bundle): # pyright: ignore[reportUnnecessaryIsInstance] + return str(MerkleProofPair(bundle[0], bundle[1])) + raise FreezeListError("A compliance proof must be a [MerkleProof; 2] literal, a MerkleProofPair, " + "or a (MerkleProof, MerkleProof) tuple") + + +def one_bundle(compliance_proofs: Any, legacy: Any = None, *, legacy_name: str = "merkle_proof") -> str | None: + """Normalise the proof arguments of a call that takes exactly one bundle. + + Accepts a :data:`ProofBundle` or a one-element tuple of one under + ``compliance_proofs``, and the literal string the call accepted before + under its old keyword. ``None`` from both means the SDK builds the proof. + + Raises: + FreezeListError: Both arguments were given, the tuple does not hold + exactly one bundle, or the value is not a proof. + """ + if legacy is not None: + if compliance_proofs is not None: + raise FreezeListError(f"Pass compliance_proofs= or {legacy_name}=, not both") + if not isinstance(legacy, str): + raise FreezeListError(f"{legacy_name}= takes the encoded [MerkleProof; 2] literal; " + "pass structured proofs through compliance_proofs=") + return bundle_literal(legacy) + if compliance_proofs is None: + return None + if isinstance(compliance_proofs, tuple): + items = cast(tuple[Any, ...], compliance_proofs) + if _is_path_tuple(items): + return bundle_literal(cast(ProofBundle, items)) + if len(items) != 1: + raise FreezeListError(f"This call takes exactly one compliance proof bundle; received {len(items)}") + return bundle_literal(cast(ProofBundle, items[0])) + return bundle_literal(cast(ProofBundle, compliance_proofs)) + + +def _is_path_tuple(value: tuple[Any, ...]) -> bool: + return len(value) == 2 and all(isinstance(p, MerkleProof) for p in value) + + +# ── Witness construction and verification ──────────────────────────────────── + +def exclusion_proof_from_tree(tree: Sequence[int], address: str, root: int, network: str) -> str: + """Build the ``[MerkleProof; 2]`` literal proving *address* is absent from *tree*. + + The published tree must be complete, sorted, and end in *root*; each + witness is recomputed up to the root before it is returned, so a stale + or corrupt tree never yields an argument the contract would reject after + the fee is paid. + + Args: + tree: Node values leaves first and root last, as the compliance API + publishes them, or an empty list for an empty freeze list. + address: The address to prove absent. + root: The on-chain ``freeze_list_root`` the contract will check. + network: ``"mainnet"`` or ``"testnet"``; selects the hash binaries. + + Raises: + FrozenAddressError: *address* is a leaf of the tree. + FreezeListError: The tree is malformed or does not end in *root*, or a + witness fails to reconstruct it. + """ + nodes = list(tree) if tree else [0, 0, EMPTY_TREE_ROOT] + leaves = (len(nodes) + 1) // 2 + if len(nodes) != 2 * leaves - 1 or leaves < 2 or leaves > 2 ** DEFAULT_DEPTH or leaves & (leaves - 1): + raise FreezeListError("Invalid freeze-list tree: expected a complete binary tree of 2..32768 leaves") + if nodes[-1] != root: + raise FreezeListError(f"Published freeze-list tree root {nodes[-1]}field does not match the on-chain root " + f"{root}field; the indexer may be lagging — retry, or pass the proof explicitly") + subject = address_to_field_int(address) + for i in range(leaves): + if nodes[i] == subject: + raise FrozenAddressError(f"{address} is on the freeze list; no exclusion proof exists for it") + if i and nodes[i] < nodes[i - 1]: + raise FreezeListError("Invalid freeze-list tree: leaves must be sorted") + left, right = leaf_indices(nodes, address) + paths = [MerkleProof(tuple(sibling_path(nodes, index, PROOF_SIBLINGS)), index) for index in (left, right)] + for path in paths: + if _reconstruct_root(path, network) != root: + raise FreezeListError("Freeze-list witness does not reconstruct the on-chain root") + return str(MerkleProofPair(paths[0], paths[1])) + + +def _reconstruct_root(path: MerkleProof, network: str) -> int: + value = path.siblings[0] + for level in range(1, PROOF_SIBLINGS): + sibling = path.siblings[level] + if level > 1 and sibling == 0: + break + right = (path.leaf_index >> (level - 1)) & 1 + prefix = "1field" if level == 1 else "0field" + hashed = hash_two(prefix, f"{sibling if right else value}field", f"{value if right else sibling}field", network) + value = int(hashed[: -len("field")]) + return value + + +# ── Which list a program checks ────────────────────────────────────────────── + +def freeze_list_from_imports(program: str, imports: Sequence[str] | None) -> str | None | _Unresolved: + """The freeze-list program *program* checks, from its name, imports, or the known table.""" + if program.endswith(FREEZELIST_SUFFIX): + return program + for dep in imports or (): + if str(dep).endswith(FREEZELIST_SUFFIX): + return str(dep) + if program in KNOWN_FREEZE_LISTS: + return KNOWN_FREEZE_LISTS[program] + return _UNRESOLVED + + +class _Unresolved: + pass + + +_UNRESOLVED = _Unresolved() + + +def _unresolved_error(program: str) -> FreezeListError: + return FreezeListError(f"{program} names no freeze-list program (no *freezelist.aleo import); " + "pass compliance_proofs= explicitly") + + +def freeze_list_program(aleo: Any, program: str) -> str | None: + """The freeze-list program that *program* checks, or ``None`` when it ignores the proof. + + A program named ``*freezelist.aleo`` is its own list. Otherwise the first + ``*freezelist.aleo`` import wins, then :data:`KNOWN_FREEZE_LISTS`. + Reads the program once through the facade when the table has no entry. + + Raises: + FreezeListError: No list could be determined for *program*. + """ + if program.endswith(FREEZELIST_SUFFIX): + return program + if program in KNOWN_FREEZE_LISTS: + return KNOWN_FREEZE_LISTS[program] + from .facade.errors import AleoError as FacadeAleoError, ProgramNotFound + try: + imports = list(aleo.programs.get(program).imports) + except (ProgramNotFound, FacadeAleoError): + imports = [] + found = freeze_list_from_imports(program, imports) + if isinstance(found, _Unresolved): + raise _unresolved_error(program) + return found + + +async def async_freeze_list_program(aleo: Any, program: str) -> str | None: + """Async twin of :func:`freeze_list_program`.""" + if program.endswith(FREEZELIST_SUFFIX): + return program + if program in KNOWN_FREEZE_LISTS: + return KNOWN_FREEZE_LISTS[program] + from .facade.errors import AleoError as FacadeAleoError, ProgramNotFound + try: + handle = await aleo.programs.get(program) + imports = list(handle.imports) + except (ProgramNotFound, FacadeAleoError): + imports = [] + found = freeze_list_from_imports(program, imports) + if isinstance(found, _Unresolved): + raise _unresolved_error(program) + return found + + +# ── Resolution with a per-client cache ─────────────────────────────────────── + +@dataclass(frozen=True) +class ProofSlot: + """One ``[MerkleProof; 2]`` argument a call needs. + + Attributes: + freezelist_program: The list the contract checks for this argument, or + ``None`` for an argument the program accepts and ignores (the + credits wrapper); such a slot receives the empty-tree pair. + address: The address whose absence the argument proves. + """ + + freezelist_program: str | None + address: str + + +@dataclass +class _CachedList: + root: int + tree: list[int] + proofs: "OrderedDict[str, str]" + + +def _parse_root(program: str, raw: Any) -> int: + text = None if raw is None else str(raw).strip().strip('"') + if text in (None, "", "null", "None"): + raise FreezeListError(f"{program}/{FREEZE_LIST_ROOT_MAPPING}[{CURRENT_ROOT_KEY}] is unreadable; " + "pass compliance_proofs= explicitly") + assert text is not None + if not text.endswith("field") or not text[:-5].isdigit(): + raise FreezeListError(f"Invalid current freeze-list root for {program}: {text!r}") + return int(text[:-5]) + + +class _ProofCache: + """Per-client memory of verified witnesses, keyed by list and pinned to the root they were built against.""" + + def __init__(self) -> None: + self._lists: "OrderedDict[tuple[str, str], _CachedList]" = OrderedDict() + + def entry(self, network: str, program: str, root: int, fetch_tree: Callable[[], list[int]]) -> _CachedList: + key = (network, program) + cached = self._lists.get(key) + if cached is not None and cached.root == root: + return cached + self._lists.pop(key, None) + tree = [0, 0, EMPTY_TREE_ROOT] if root == EMPTY_TREE_ROOT else fetch_tree() + entry = _CachedList(root, tree, OrderedDict()) + while len(self._lists) >= MAX_CACHED_LISTS: + self._lists.popitem(last=False) + self._lists[key] = entry + return entry + + def current(self, network: str, program: str, root: int) -> _CachedList | None: + """The cached entry for *program* when it was built against *root*, else ``None``.""" + cached = self._lists.get((network, program)) + return cached if cached is not None and cached.root == root else None + + def forget(self, network: str, program: str, entry: _CachedList) -> None: + if self._lists.get((network, program)) is entry: + del self._lists[(network, program)] + + def clear(self) -> None: + self._lists.clear() + + def __len__(self) -> int: + return len(self._lists) + + +def _witness(entry: _CachedList, network: str, address: str) -> str: + if entry.root == EMPTY_TREE_ROOT: + return EMPTY_MERKLE_PROOF_PAIR # the same pair for every address; nothing to build or cache + proof = entry.proofs.get(address) + if proof is None: + proof = exclusion_proof_from_tree(entry.tree, address, entry.root, network) + while len(entry.proofs) >= MAX_CACHED_ADDRESSES: + entry.proofs.popitem(last=False) + entry.proofs[address] = proof + return proof + + +def _fetched_tree(program: str, nodes: list[int] | None) -> list[int]: + if nodes is None: + raise FreezeListError(f"{program} publishes no compliance tree; pass compliance_proofs= explicitly") + return nodes + + +class FreezeListProofs: + """Resolve and cache freeze-list exclusion proofs for one client. + + Each resolution reads the list's current root (one mapping read per list + per call). While the root is unchanged, the published tree and every + witness built from it are reused from memory; a changed root discards + them and fetches the tree again. The cache holds at most 16 lists and + 256 addresses per list and never outlives the process. + + Args: + aleo: The bound ``Aleo`` facade; reads go through its network module. + """ + + def __init__(self, aleo: Any) -> None: + self._aleo = aleo + self._cache = _ProofCache() + + @property + def network(self) -> str: + return str(self._aleo.network_name) + + def clear(self) -> None: + """Drop every cached tree and witness; the next call reads the chain again.""" + self._cache.clear() + + def root(self, program: str) -> int: + """The list's current on-chain root (``freeze_list_root[1u8]``) as an integer.""" + try: + raw = self._aleo.network.get_program_mapping_value(program, FREEZE_LIST_ROOT_MAPPING, CURRENT_ROOT_KEY) + except AleoNetworkError as exc: + if exc.status == 404: + raw = None + else: + raise + return _parse_root(program, raw) + + def tree(self, program: str, root: int | None = None) -> list[int]: + """The published tree for *program*, verified to end in *root* (read from chain when omitted).""" + root = self.root(program) if root is None else root + return self._entry(program, root).tree + + def exclusion_proof(self, address: str, program: str) -> str: + """The ``[MerkleProof; 2]`` literal proving *address* is not on *program*'s list. + + *program* may be the freeze-list program or a token program that + imports one. Returns the empty-tree pair while the list is empty. + + Raises: + FrozenAddressError: *address* is frozen. + FreezeListError: The root is unreadable, the published tree does + not match it, or no list can be found for *program*. + """ + freezelist = freeze_list_program(self._aleo, program) + if freezelist is None: + return EMPTY_MERKLE_PROOF_PAIR + return self.resolve([ProofSlot(freezelist, address)])[0] + + def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: + """One ``[MerkleProof; 2]`` literal per slot, in order. + + Every slot naming the same list is proved against one root read, so + a call that takes several proofs for one list cannot mix roots. + ``None`` slots and slots whose list is ``None`` get the empty pair. + """ + roots: dict[str, int] = {} + out: list[str] = [] + for slot in slots: + if slot is None or slot.freezelist_program is None: + out.append(EMPTY_MERKLE_PROOF_PAIR) + continue + program = slot.freezelist_program + if program not in roots: + roots[program] = self.root(program) + entry = self._entry(program, roots[program]) + try: + out.append(_witness(entry, self.network, slot.address)) + except FrozenAddressError: + raise # a true answer from a good tree: keep it cached + except FreezeListError: + self._cache.forget(self.network, program, entry) # a stale or corrupt tree: fetch again next time + raise + return out + + def _entry(self, program: str, root: int) -> _CachedList: + def fetch() -> list[int]: + return _fetched_tree(program, self._aleo.network.get_program_freeze_list(program)) + return self._cache.entry(self.network, program, root, fetch) + + +class AsyncFreezeListProofs: + """Async twin of :class:`FreezeListProofs` over an ``AsyncAleo`` facade.""" + + def __init__(self, aleo: Any) -> None: + self._aleo = aleo + self._cache = _ProofCache() + + @property + def network(self) -> str: + return str(self._aleo.network_name) + + def clear(self) -> None: + """Drop every cached tree and witness; the next call reads the chain again.""" + self._cache.clear() + + async def root(self, program: str) -> int: + """The list's current on-chain root (``freeze_list_root[1u8]``) as an integer.""" + try: + raw = await self._aleo.network.get_program_mapping_value(program, FREEZE_LIST_ROOT_MAPPING, CURRENT_ROOT_KEY) + except AleoNetworkError as exc: + if exc.status == 404: + raw = None + else: + raise + return _parse_root(program, raw) + + async def tree(self, program: str, root: int | None = None) -> list[int]: + """The published tree for *program*, verified to end in *root* (read from chain when omitted).""" + root = await self.root(program) if root is None else root + return (await self._entry(program, root)).tree + + async def exclusion_proof(self, address: str, program: str) -> str: + """The ``[MerkleProof; 2]`` literal proving *address* is not on *program*'s list (awaited).""" + freezelist = await async_freeze_list_program(self._aleo, program) + if freezelist is None: + return EMPTY_MERKLE_PROOF_PAIR + return (await self.resolve([ProofSlot(freezelist, address)]))[0] + + async def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: + """One ``[MerkleProof; 2]`` literal per slot, in order; one root read per list per call.""" + roots: dict[str, int] = {} + out: list[str] = [] + for slot in slots: + if slot is None or slot.freezelist_program is None: + out.append(EMPTY_MERKLE_PROOF_PAIR) + continue + program = slot.freezelist_program + if program not in roots: + roots[program] = await self.root(program) + entry = await self._entry(program, roots[program]) + try: + out.append(_witness(entry, self.network, slot.address)) + except FrozenAddressError: + raise # a true answer from a good tree: keep it cached + except FreezeListError: + self._cache.forget(self.network, program, entry) # a stale or corrupt tree: fetch again next time + raise + return out + + async def _entry(self, program: str, root: int) -> _CachedList: + network = self.network + if root != EMPTY_TREE_ROOT and self._cache.current(network, program, root) is None: + nodes = _fetched_tree(program, await self._aleo.network.get_program_freeze_list(program)) + return self._cache.entry(network, program, root, lambda: nodes) + return self._cache.entry(network, program, root, _never) + + +def _never() -> list[int]: # pragma: no cover - only reached on a logic error + raise AssertionError("tree fetch requested for a cached or empty list") + + +def _network_module(network: str) -> Any: + import importlib + return importlib.import_module(f"aleo.{network}") + + +__all__ = [ + "CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", + "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_ROOT_MAPPING", + "KNOWN_FREEZE_LISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", + "AsyncFreezeListProofs", "FreezeListError", "FreezeListProofs", "FrozenAddressError", + "MerkleProof", "MerkleProofPair", "ProofBundle", "ProofSlot", + "address_to_field_int", "async_freeze_list_program", "build_tree", "bundle_literal", + "exclusion_proof_from_tree", "format_merkle_proof", "freeze_list_from_imports", "freeze_list_program", + "generate_leaves", "hash_two", "leaf_indices", "one_bundle", "sibling_path", +] diff --git a/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index 1b1cf316..59101f2a 100644 --- a/sdk/python/aleo/facade/async_client.py +++ b/sdk/python/aleo/facade/async_client.py @@ -575,6 +575,15 @@ async def get_program_mapping_value( program_id, mapping_name, key ) + async def get_program_freeze_list(self, program_id: str) -> list[int] | None: + """Return the published compliance Merkle tree of a freeze-list program (awaited). + + Node values leaves first and root last; ``None`` when the program has + no freeze list. Verify the root against ``freeze_list_root`` before + building a witness. + """ + return await self._nc().get_program_freeze_list(program_id) + async def get_public_balance(self, address: str) -> int: """Return the public credits balance for *address* in microcredits. diff --git a/sdk/python/aleo/facade/network.py b/sdk/python/aleo/facade/network.py index 1216c94d..70e51917 100644 --- a/sdk/python/aleo/facade/network.py +++ b/sdk/python/aleo/facade/network.py @@ -251,6 +251,23 @@ def get_program_mapping_value( """ return self._nc().get_program_mapping_value(program_id, mapping_name, key) + def get_program_freeze_list(self, program_id: str) -> list[int] | None: + """Return the published compliance Merkle tree of a freeze-list program. + + Parameters + ---------- + program_id: + The program that owns the freeze list (``usdcx_freezelist.aleo``). + + Returns + ------- + list[int] | None + Node values, leaves first and root last; ``None`` when the program + has no freeze list. Verify the root against the program's + ``freeze_list_root`` mapping before building a witness. + """ + return self._nc().get_program_freeze_list(program_id) + def get_public_balance(self, address: str) -> int: """Return the public credits balance for *address* in microcredits. diff --git a/sdk/python/aleo/network_client.py b/sdk/python/aleo/network_client.py index 278a1a6c..aca5745d 100644 --- a/sdk/python/aleo/network_client.py +++ b/sdk/python/aleo/network_client.py @@ -37,6 +37,7 @@ strip_quotes, validate_block_range, retry_with_backoff, + parse_freeze_list_tree, ) from .security import encrypt_proving_request @@ -749,6 +750,36 @@ def get_program_mapping_plaintext( ) return Plaintext.from_string(json.loads(raw)) + def get_program_freeze_list(self, program_id: str) -> list[int] | None: + """Read the published compliance Merkle tree of a freeze-list program. + + The tree is what an exclusion proof is built from: node values from the + leaf layer up to the root, as integers. The indexer can lag a root + update, so compare the last node with the program's on-chain + ``freeze_list_root`` before using a witness built from it. + + Args: + program_id: The program that owns the freeze list, e.g. + ``"usdcx_freezelist.aleo"`` — not the token program that + imports it. + + Returns: + The tree nodes, leaves first and root last; an empty list is + published as two zero leaves plus the empty-tree root. ``None`` when + the program has no freeze list. + + Raises: + AleoNetworkError: If the request fails for another reason, or the + response is not a list of decimal node values. + """ + try: + raw = self._get(f"/programs/{program_id}/compliance/freeze-list", "getProgramFreezeList") + except AleoNetworkError as exc: + if exc.status == 404: + return None + raise + return parse_freeze_list_tree(raw) + def get_public_balance(self, address: str) -> int: """Read an address's public ``credits.aleo`` balance. diff --git a/sdk/python/tests/test_compliance.py b/sdk/python/tests/test_compliance.py new file mode 100644 index 00000000..cccd2d94 --- /dev/null +++ b/sdk/python/tests/test_compliance.py @@ -0,0 +1,423 @@ +"""aleo.compliance — freeze-list trees, exclusion proofs, and the per-client proof cache. + +Tree vectors come from ProvableHQ/sdk ``sdk/src/integrations/sealance/merkle-tree.ts`` +docstrings (moved here from the bridge package's test_freezelist.py). Resolution tests +run against a fake facade that scripts the root mapping and the compliance endpoint. +""" +from __future__ import annotations + +from typing import Any + +import pytest + +from aleo import compliance as fl +from aleo.compliance import ( + EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, AsyncFreezeListProofs, FreezeListError, FreezeListProofs, + FrozenAddressError, MerkleProof, MerkleProofPair, ProofSlot, bundle_literal, exclusion_proof_from_tree, + freeze_list_program, one_bundle, +) + +A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +B = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t" +A_FIELD = 3501665755452795161867664882580888971213780722176652848275908626939553697821 +B_FIELD = 1295133970529764960316948294624974168921228814652993007266766481909235735940 +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +C = "aleo15mstsvdtzqf5nw8rfzx8mrllwxt907amfpt8nx3p8cskj4wd3uxq4uywn9" +C_FIELD = 5681830473930530271878304807765952613665599463594380247149021024878193074342 +LOW = "aleo13rmacw9jk943wwg4s6t5yxcycvl3ud356l3cyzgav8hw9fkcjsqsgt4vy3" # sorts below B +MID = "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf" # sorts between B and A +ZERO = fl.ZERO_ADDRESS +LIST = "usdcx_freezelist.aleo" +EMPTY_ONE = "{ siblings: [" + ", ".join(["0field"] * 16) + "], leaf_index: 1u32 }" +NET = "mainnet" + + +# ── Tree math (vectors) ─────────────────────────────────────────────────────── + +def test_empty_pair_literal_matches_veil(): + assert EMPTY_MERKLE_PROOF_PAIR == f"[{EMPTY_ONE}, {EMPTY_ONE}]" + assert EMPTY_MERKLE_PROOF_PAIR.count("0field") == 32 and EMPTY_MERKLE_PROOF_PAIR.count("leaf_index: 1u32") == 2 + assert fl.PROOF_SIBLINGS == 16 and fl.DEFAULT_DEPTH == 15 + + +def test_address_to_field_int_matches_ts_docstrings(): + assert fl.address_to_field_int(A) == A_FIELD + assert fl.address_to_field_int(B) == B_FIELD + assert fl.address_to_field_int(ZERO) == 0 + with pytest.raises(FreezeListError, match="Not an Aleo address"): + fl.address_to_field_int("aleo1nope") + + +def test_hash_two_uses_poseidon4_over_packed_array(): + from aleo import mainnet as net + expected = str(net.Poseidon4().hash(net.Plaintext.from_string("[1field,3field,4field]").to_fields())) + assert fl.hash_two("1field", "3field", "4field", NET) == expected + assert fl.hash_two("0field", "3field", "4field", NET) != expected # prefix matters (leaf vs node) + + +def test_empty_tree_root_pinned(): + # Pinned 2026-09-03 from the live compliance tree ([0, 0, root]); investigate rather than re-pin on failure. + assert fl.build_tree(["0field", "0field"], NET) == [0, 0, EMPTY_TREE_ROOT] + + +def test_generate_leaves_sorts_pads_and_filters_zero(): + assert fl.generate_leaves([A, B, B]) == ["0field", f"{B_FIELD}field", f"{B_FIELD}field", f"{A_FIELD}field"] + assert fl.generate_leaves([ZERO, ZERO, A]) == ["0field", f"{A_FIELD}field"] + assert fl.generate_leaves([]) == ["0field", "0field"] + assert len(fl.generate_leaves([A] * 5)) == 8 and fl.generate_leaves([A] * 5)[:3] == ["0field"] * 3 + with pytest.raises(FreezeListError, match="Leaves limit exceeded"): + fl.generate_leaves([A] * (2 ** 14 + 1), 15) + + +def test_build_tree_shapes_and_errors(): + two = fl.build_tree(["1field", "2field"], NET) + four = fl.build_tree(["1field", "2field", "3field", "4field"], NET) + assert len(two) == 3 and two[:2] == [1, 2] + assert len(four) == 7 and four[:4] == [1, 2, 3, 4] + assert f"{four[4]}field" == fl.hash_two("1field", "1field", "2field", NET) + assert f"{four[6]}field" == fl.hash_two("0field", f"{four[4]}field", f"{four[5]}field", NET) + with pytest.raises(FreezeListError, match="cannot be empty"): + fl.build_tree([], NET) + with pytest.raises(FreezeListError, match="even number"): + fl.build_tree(["1field", "2field", "3field"], NET) + + +def test_leaf_indices_and_sibling_path(): + tree = fl.build_tree(fl.generate_leaves([A]), NET) # leaves [0, A_FIELD] + assert fl.leaf_indices(tree, RECIPIENT) == (1, 1) + assert fl.leaf_indices(tree, A) == (0, 1) + four = fl.build_tree(["1field", "2field", "3field", "4field"], NET) + path = fl.sibling_path(four, 1, 15) + assert len(path) == 15 and path[:3] == [2, 1, four[5]] and path[3:] == [0] * 12 + assert len(fl.sibling_path(four, 1, 16)) == 16 + assert fl.format_merkle_proof(([2, 1] + [0] * 14, 1), ([3, 4] + [0] * 14, 2)).startswith( + "[{ siblings: [2field, 1field, 0field") + + +def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): + tree = fl.build_tree(["0field", "0field"], NET) + left, right = fl.leaf_indices(tree, RECIPIENT) + assert (left, right) == (1, 1) + assert fl.format_merkle_proof((fl.sibling_path(tree, left, 16), left), + (fl.sibling_path(tree, right, 16), right)) == EMPTY_MERKLE_PROOF_PAIR + + +# ── Proof values ────────────────────────────────────────────────────────────── + +def test_merkle_proof_round_trips_through_its_literal(): + one = MerkleProof(tuple([A_FIELD, 0] + [0] * 14), 1) + assert str(one) == "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" + assert MerkleProof.from_string(str(one)) == one + pair = MerkleProofPair(one, one) + assert MerkleProofPair.from_string(str(pair)) == pair + assert MerkleProofPair.from_string(EMPTY_MERKLE_PROOF_PAIR) == MerkleProofPair( + MerkleProof((0,) * 16, 1), MerkleProof((0,) * 16, 1)) + with pytest.raises(FreezeListError, match="16 siblings"): + MerkleProof((1, 2), 0) + with pytest.raises(FreezeListError, match="Not a MerkleProof"): + MerkleProof.from_string("[nope]") + with pytest.raises(FreezeListError, match="two proofs"): + MerkleProofPair.from_string(f"[{EMPTY_ONE}]") + + +def test_bundle_literal_accepts_every_supported_form(): + one = MerkleProof((0,) * 16, 1) + assert bundle_literal(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR + assert bundle_literal(f" {EMPTY_MERKLE_PROOF_PAIR} ") == EMPTY_MERKLE_PROOF_PAIR + assert bundle_literal(MerkleProofPair(one, one)) == EMPTY_MERKLE_PROOF_PAIR + assert bundle_literal((one, one)) == EMPTY_MERKLE_PROOF_PAIR + with pytest.raises(FreezeListError, match="encoded"): + bundle_literal("not a literal") + with pytest.raises(FreezeListError, match="MerkleProofPair"): + bundle_literal((one,)) # type: ignore[arg-type] + + +def test_one_bundle_handles_new_old_and_conflicting_arguments(): + one = MerkleProof((0,) * 16, 1) + assert one_bundle(None) is None + assert one_bundle(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR + assert one_bundle((EMPTY_MERKLE_PROOF_PAIR,)) == EMPTY_MERKLE_PROOF_PAIR + assert one_bundle((one, one)) == EMPTY_MERKLE_PROOF_PAIR # a bare path tuple is one bundle + assert one_bundle(((one, one),)) == EMPTY_MERKLE_PROOF_PAIR # a one-tuple of a path tuple too + assert one_bundle(None, EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR + with pytest.raises(FreezeListError, match="not both"): + one_bundle(EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR) + with pytest.raises(FreezeListError, match="exactly one"): + one_bundle((EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR)) + with pytest.raises(FreezeListError, match="merkle_proof= takes the encoded"): + one_bundle(None, (one, one)) + + +# ── Witnesses against a populated tree ─────────────────────────────────────── + +def _populated() -> tuple[list[int], int]: + tree = fl.build_tree(fl.generate_leaves([A, B, C]), NET) # leaves [0, B, A, C] (B < A < C) + assert tree[:4] == [0, B_FIELD, A_FIELD, C_FIELD] + return tree, tree[-1] + + +def test_exclusion_proof_from_tree_builds_verified_witnesses(): + tree, root = _populated() + literal = exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + pair = MerkleProofPair.from_string(literal) + # RECIPIENT sorts above every leaf, so both paths open the last leaf (C). + assert pair.left.leaf_index == 3 and pair.right.leaf_index == 3 + assert pair.left.siblings[0] == C_FIELD and pair.left.siblings[1] == A_FIELD and pair.left.siblings[2] == tree[4] + assert pair.left.siblings[3:] == (0,) * 13 + assert fl._reconstruct_root(pair.left, NET) == root + # LOW sorts between the zero pad and B: the pair opens leaves 0 and 1. + low = MerkleProofPair.from_string(exclusion_proof_from_tree(tree, LOW, root, NET)) + assert (low.left.leaf_index, low.right.leaf_index) == (0, 1) + assert low.left.siblings[0] == 0 and low.right.siblings[0] == B_FIELD + # MID sorts between B and A: leaves 1 and 2. + mid = MerkleProofPair.from_string(exclusion_proof_from_tree(tree, MID, root, NET)) + assert (mid.left.leaf_index, mid.right.leaf_index) == (1, 2) + # The zero address equals the padding leaf and is reported as frozen, like any leaf value. + with pytest.raises(FrozenAddressError): + exclusion_proof_from_tree(tree, ZERO, root, NET) + + +def test_exclusion_proof_from_tree_empty_list_is_the_fixed_pair(): + assert exclusion_proof_from_tree([], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR + assert exclusion_proof_from_tree([0, 0, EMPTY_TREE_ROOT], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR + + +def test_exclusion_proof_from_tree_rejects_bad_input(): + tree, root = _populated() + with pytest.raises(FrozenAddressError, match="on the freeze list"): + exclusion_proof_from_tree(tree, A, root, NET) + with pytest.raises(FreezeListError, match="does not match the on-chain root"): + exclusion_proof_from_tree(tree, RECIPIENT, root + 1, NET) + with pytest.raises(FreezeListError, match="complete binary tree"): + exclusion_proof_from_tree(tree[:-1], RECIPIENT, root, NET) + unsorted = [0, A_FIELD, B_FIELD, C_FIELD, *tree[4:]] + with pytest.raises(FreezeListError, match="sorted"): + exclusion_proof_from_tree(unsorted, RECIPIENT, root, NET) + corrupt = list(tree) + corrupt[4] += 1 # an inner node that does not hash from its leaves + with pytest.raises(FreezeListError, match="does not reconstruct"): + exclusion_proof_from_tree(corrupt, RECIPIENT, root, NET) + + +# ── Fake facade ─────────────────────────────────────────────────────────────── + +class _Program: + def __init__(self, imports: list[str]) -> None: + self.imports = imports + + +class _Programs: + def __init__(self, fake: "FakeAleo") -> None: + self._fake = fake + + def get(self, program_id: str) -> _Program: + from aleo.facade.errors import ProgramNotFound + self._fake.fetched.append(program_id) + if program_id not in self._fake.imports: + raise ProgramNotFound(program_id) + return _Program(self._fake.imports[program_id]) + + +class _Network: + def __init__(self, fake: "FakeAleo") -> None: + self._fake = fake + + def get_program_mapping_value(self, program_id: str, mapping: str, key: str) -> Any: + self._fake.mapping_reads.append((program_id, mapping, key)) + return self._fake.mappings.get(program_id, {}).get(mapping, {}).get(key) + + def get_program_freeze_list(self, program_id: str) -> list[int] | None: + self._fake.tree_reads.append(program_id) + tree = self._fake.trees.get(program_id) + if isinstance(tree, Exception): + raise tree + return tree + + +class FakeAleo: + network_name = NET + + def __init__(self) -> None: + self.mappings: dict[str, dict[str, dict[str, str]]] = {} + self.trees: dict[str, Any] = {} + self.imports: dict[str, list[str]] = {} + self.fetched: list[str] = [] + self.mapping_reads: list[tuple[str, str, str]] = [] + self.tree_reads: list[str] = [] + self.programs = _Programs(self) + self.network = _Network(self) + + def set_root(self, program: str, root: int | str) -> None: + value = f"{root}field" if isinstance(root, int) else root + self.mappings.setdefault(program, {}).setdefault("freeze_list_root", {})["1u8"] = value + + +class _AsyncPrograms(_Programs): + async def get(self, program_id: str) -> _Program: # type: ignore[override] + return super().get(program_id) + + +class _AsyncNetwork(_Network): + async def get_program_mapping_value(self, program_id: str, mapping: str, key: str) -> Any: # type: ignore[override] + return super().get_program_mapping_value(program_id, mapping, key) + + async def get_program_freeze_list(self, program_id: str) -> list[int] | None: # type: ignore[override] + return super().get_program_freeze_list(program_id) + + +class FakeAsyncAleo(FakeAleo): + def __init__(self) -> None: + super().__init__() + self.programs = _AsyncPrograms(self) + self.network = _AsyncNetwork(self) + + +# ── Which list a program checks ────────────────────────────────────────────── + +def test_freeze_list_program_resolution_order(): + fake = FakeAleo() + assert freeze_list_program(fake, LIST) == LIST # already a list + assert freeze_list_program(fake, "usdcx_stablecoin.aleo") == LIST # known table, no fetch + assert freeze_list_program(fake, "credits.aleo") is None # ignores the proof + assert fake.fetched == [] + fake.imports["shield_swap_arc20_wrapped_usdcx.aleo"] = ["merkle_tree.aleo", "test_usdcx_freezelist.aleo"] + assert freeze_list_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" + with pytest.raises(FreezeListError, match="names no freeze-list program"): + freeze_list_program(fake, "arc20_wbtc.aleo") + + +def test_freeze_list_program_propagates_unrelated_exceptions(): + fake = FakeAleo() + + def boom(program_id: str) -> _Program: + raise RuntimeError("rpc hiccup") + + fake.programs.get = boom # type: ignore[method-assign] + with pytest.raises(RuntimeError, match="rpc hiccup"): + freeze_list_program(fake, "arc20_wbtc.aleo") + + +# ── Resolution and caching ──────────────────────────────────────────────────── + +def test_empty_root_short_circuits_to_the_empty_pair_without_fetching_the_tree(): + fake = FakeAleo() + fake.set_root(LIST, EMPTY_TREE_ROOT) + proofs = FreezeListProofs(fake) + assert proofs.exclusion_proof(RECIPIENT, "usdcx_stablecoin.aleo") == EMPTY_MERKLE_PROOF_PAIR + assert proofs.exclusion_proof(RECIPIENT, "credits.aleo") == EMPTY_MERKLE_PROOF_PAIR + assert fake.tree_reads == [] + assert fake.mapping_reads == [(LIST, "freeze_list_root", "1u8")] + + +def test_unreadable_or_invalid_root_is_fatal(): + fake = FakeAleo() + proofs = FreezeListProofs(fake) + with pytest.raises(FreezeListError, match=r"freeze_list_root\[1u8\] is unreadable"): + proofs.exclusion_proof(RECIPIENT, LIST) + fake.set_root(LIST, "garbage") + with pytest.raises(FreezeListError, match="Invalid current freeze-list root"): + proofs.exclusion_proof(RECIPIENT, LIST) + + +def test_populated_list_is_proved_from_the_published_tree_and_cached_per_root(): + fake = FakeAleo() + tree, root = _populated() + fake.set_root(LIST, root) + fake.trees[LIST] = tree + proofs = FreezeListProofs(fake) + first = proofs.exclusion_proof(RECIPIENT, LIST) + assert first == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert proofs.exclusion_proof(RECIPIENT, LIST) == first + assert proofs.exclusion_proof(LOW, LIST) != first # another subject, same tree + assert fake.tree_reads == [LIST] # one fetch while the root is unchanged + assert len(fake.mapping_reads) == 3 # the root is re-read every call + with pytest.raises(FrozenAddressError): + proofs.exclusion_proof(A, LIST) + # The list changes: new root → the old tree is discarded and fetched again. + bigger = fl.build_tree(fl.generate_leaves([A, B, RECIPIENT]), NET) + fake.set_root(LIST, bigger[-1]) + fake.trees[LIST] = bigger + with pytest.raises(FrozenAddressError): + proofs.exclusion_proof(RECIPIENT, LIST) + assert fake.tree_reads == [LIST, LIST] + assert proofs.exclusion_proof(LOW, LIST) == exclusion_proof_from_tree(bigger, LOW, bigger[-1], NET) + proofs.clear() + proofs.exclusion_proof(LOW, LIST) + assert fake.tree_reads == [LIST, LIST, LIST] + + +def test_stale_published_tree_is_not_cached(): + fake = FakeAleo() + tree, root = _populated() + fake.set_root(LIST, root + 1) # chain moved, indexer still serves the old tree + fake.trees[LIST] = tree + proofs = FreezeListProofs(fake) + with pytest.raises(FreezeListError, match="does not match the on-chain root"): + proofs.exclusion_proof(RECIPIENT, LIST) + assert len(proofs._cache) == 0 + fake.trees[LIST] = FreezeListError("indexer down") + with pytest.raises(FreezeListError, match="indexer down"): + proofs.exclusion_proof(RECIPIENT, LIST) + fake.trees[LIST] = None + with pytest.raises(FreezeListError, match="publishes no compliance tree"): + proofs.exclusion_proof(RECIPIENT, LIST) + assert len(proofs._cache) == 0 + + +def test_resolve_reads_each_list_root_once_per_call_and_fills_placeholders(): + fake = FakeAleo() + tree, root = _populated() + fake.set_root(LIST, root) + fake.trees[LIST] = tree + fake.set_root("shield_swap_freezelist.aleo", EMPTY_TREE_ROOT) + proofs = FreezeListProofs(fake) + out = proofs.resolve([ProofSlot("shield_swap_freezelist.aleo", RECIPIENT), None, ProofSlot(None, RECIPIENT), + ProofSlot(LIST, RECIPIENT), ProofSlot(LIST, LOW), ProofSlot("shield_swap_freezelist.aleo", LOW)]) + assert out[0] == out[1] == out[2] == out[5] == EMPTY_MERKLE_PROOF_PAIR + assert out[3] == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert out[4] == exclusion_proof_from_tree(tree, LOW, root, NET) + assert fake.mapping_reads == [("shield_swap_freezelist.aleo", "freeze_list_root", "1u8"), (LIST, "freeze_list_root", "1u8")] + + +def test_cache_is_bounded(): + fake = FakeAleo() + proofs = FreezeListProofs(fake) + for i in range(fl.MAX_CACHED_LISTS + 2): + fake.set_root(f"l{i}_freezelist.aleo", EMPTY_TREE_ROOT) + proofs.exclusion_proof(RECIPIENT, f"l{i}_freezelist.aleo") + assert len(proofs._cache) == fl.MAX_CACHED_LISTS + tree, root = _populated() + fake.set_root(LIST, root) + fake.trees[LIST] = tree + entry = proofs._entry(LIST, root) + for i in range(fl.MAX_CACHED_ADDRESSES + 5): + entry.proofs[f"addr{i}"] = "x" + if len(entry.proofs) > fl.MAX_CACHED_ADDRESSES: + entry.proofs.popitem(last=False) + proofs.exclusion_proof(RECIPIENT, LIST) + assert len(entry.proofs) == fl.MAX_CACHED_ADDRESSES + + +@pytest.mark.asyncio +async def test_async_resolver_matches_sync(): + fake = FakeAsyncAleo() + tree, root = _populated() + fake.set_root(LIST, root) + fake.trees[LIST] = tree + fake.set_root("shield_swap_freezelist.aleo", EMPTY_TREE_ROOT) + fake.imports["shield_swap_arc20_wrapped_usdcx.aleo"] = ["test_usdcx_freezelist.aleo"] + proofs = AsyncFreezeListProofs(fake) + assert await proofs.exclusion_proof(RECIPIENT, "credits.aleo") == EMPTY_MERKLE_PROOF_PAIR + assert await proofs.exclusion_proof(RECIPIENT, "usdcx_stablecoin.aleo") == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert await proofs.exclusion_proof(RECIPIENT, LIST) == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert fake.tree_reads == [LIST] + out = await proofs.resolve([ProofSlot("shield_swap_freezelist.aleo", RECIPIENT), ProofSlot(LIST, LOW), None]) + assert out[0] == out[2] == EMPTY_MERKLE_PROOF_PAIR and out[1] == exclusion_proof_from_tree(tree, LOW, root, NET) + assert await fl.async_freeze_list_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" + with pytest.raises(FrozenAddressError): + await proofs.exclusion_proof(A, LIST) + fake.set_root(LIST, root + 1) + with pytest.raises(FreezeListError, match="does not match"): + await proofs.exclusion_proof(RECIPIENT, LIST) + assert proofs._cache.current(NET, LIST, root + 1) is None # the stale tree was not kept + assert len(proofs._cache) == 1 # the shield_swap list entry survives diff --git a/sdk/python/tests/test_facade_async.py b/sdk/python/tests/test_facade_async.py index 870dfbcb..100f5ae7 100644 --- a/sdk/python/tests/test_facade_async.py +++ b/sdk/python/tests/test_facade_async.py @@ -580,3 +580,13 @@ async def test_async_decode_transition_by_id_404_raises_transaction_not_found() ) with pytest.raises(TransactionNotFound): await a.decode_transition("at1fake") + + +@pytest.mark.asyncio +async def test_async_network_get_program_freeze_list() -> None: + a = _make_aleo({ + f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list": jr(["0", "0", "7"]), + f"{HOST}/programs/credits.aleo/compliance/freeze-list": jr({"statusCode": 404}, 404), + }) + assert await a.network.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] + assert await a.network.get_program_freeze_list("credits.aleo") is None diff --git a/sdk/python/tests/test_facade_network.py b/sdk/python/tests/test_facade_network.py index 140c4202..ea7b9116 100644 --- a/sdk/python/tests/test_facade_network.py +++ b/sdk/python/tests/test_facade_network.py @@ -567,3 +567,12 @@ def fake_transport(method: str, url: str, **kwargs: Any) -> MagicMock: result = aleo.network.wait_for_transaction(TX_ID, timeout=30.0, poll_interval=0.1) assert result["status"] == "accepted" assert attempt[0] == 3 + + +@resp_lib.activate +def test_get_program_freeze_list() -> None: + resp_lib.add(resp_lib.GET, f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list", json=["0", "0", "7"]) + resp_lib.add(resp_lib.GET, f"{HOST}/programs/credits.aleo/compliance/freeze-list", json={"statusCode": 404}, status=404) + a = make_client() + assert a.network.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] + assert a.network.get_program_freeze_list("credits.aleo") is None diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py index 765c3bc2..bea19b1b 100644 --- a/sdk/python/tests/test_network_client.py +++ b/sdk/python/tests/test_network_client.py @@ -935,3 +935,45 @@ def transport(method: str, url: str, **kwargs: Any) -> Any: # The legacy host still mints as before (covered by the JWT tests above). legacy = AleoNetworkClient("https://api.provable.com", network="testnet") assert legacy._ensure_jwt(None, None, None) is None # no creds → nothing to mint + + +# --------------------------------------------------------------------------- +# Compliance freeze list +# --------------------------------------------------------------------------- + +EMPTY_FREEZE_LIST_ROOT = "3642222252059314292809609689035560016959342421640560347114299934615987159853" + + +@resp_lib.activate +def test_get_program_freeze_list_returns_tree_nodes_as_ints() -> None: + resp_lib.add(resp_lib.GET, f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list", + json=["0", "0", EMPTY_FREEZE_LIST_ROOT]) + c = make_client() + assert c.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] + assert resp_lib.calls[0].request.url == f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list" + + +@resp_lib.activate +def test_get_program_freeze_list_is_none_when_the_program_has_no_list() -> None: + resp_lib.add(resp_lib.GET, f"{HOST}/programs/credits.aleo/compliance/freeze-list", + json={"statusCode": 404, "message": "No current freeze list found for program: credits.aleo"}, status=404) + resp_lib.add(resp_lib.GET, f"{HOST}/programs/nolist.aleo/compliance/freeze-list", body="null", content_type="application/json") + c = make_client() + assert c.get_program_freeze_list("credits.aleo") is None + assert c.get_program_freeze_list("nolist.aleo") is None + + +@resp_lib.activate +def test_get_program_freeze_list_rejects_non_decimal_nodes() -> None: + resp_lib.add(resp_lib.GET, f"{HOST}/programs/bad.aleo/compliance/freeze-list", json=["0", "x"]) + c = make_client() + with pytest.raises(AleoNetworkError, match="decimal"): + c.get_program_freeze_list("bad.aleo") + + +@resp_lib.activate +def test_get_program_freeze_list_propagates_other_errors() -> None: + resp_lib.add(resp_lib.GET, f"{HOST}/programs/down.aleo/compliance/freeze-list", json={"error": "boom"}, status=403) + c = make_client() + with pytest.raises(AleoNetworkError): + c.get_program_freeze_list("down.aleo") diff --git a/sdk/python/tests/test_network_client_async.py b/sdk/python/tests/test_network_client_async.py index 6d4f8171..875ab4e6 100644 --- a/sdk/python/tests/test_network_client_async.py +++ b/sdk/python/tests/test_network_client_async.py @@ -596,3 +596,25 @@ def handler(req: httpx.Request) -> httpx.Response: assert result["ok"] is False assert result["status"] == 400 assert post_count == 1 + + +# --------------------------------------------------------------------------- +# Compliance freeze list +# --------------------------------------------------------------------------- + +EMPTY_FREEZE_LIST_ROOT = "3642222252059314292809609689035560016959342421640560347114299934615987159853" + + +@pytest.mark.asyncio +async def test_async_get_program_freeze_list() -> None: + c = make_client({ + f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list": jr(["0", "0", EMPTY_FREEZE_LIST_ROOT]), + f"{HOST}/programs/credits.aleo/compliance/freeze-list": jr({"statusCode": 404}, 404), + f"{HOST}/programs/nolist.aleo/compliance/freeze-list": httpx.Response(200, content=b"null", headers={"content-type": "application/json"}), + f"{HOST}/programs/bad.aleo/compliance/freeze-list": jr(["0", "x"]), + }) + assert await c.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] + assert await c.get_program_freeze_list("credits.aleo") is None + assert await c.get_program_freeze_list("nolist.aleo") is None + with pytest.raises(AleoNetworkError, match="decimal"): + await c.get_program_freeze_list("bad.aleo") diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 62707336..a9fad179 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -418,7 +418,7 @@ Returns the API's best route and final slippage floor; pass it to ``swap(quote)``. Requires API authentication. Quoting spends no funds and needs no record scanning or transaction signature. -### `swap(self, quote: 'Optional[SwapQuote]' = None, *, pool_key: 'Optional[str]' = None, token_in_id: 'Optional[str]' = None, amount_in: 'Optional[int | str | Decimal]' = None, slippage_bps: 'Optional[int]' = None, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, quote: 'Optional[SwapQuote]' = None, *, pool_key: 'Optional[str]' = None, token_in_id: 'Optional[str]' = None, amount_in: 'Optional[int | str | Decimal]' = None, slippage_bps: 'Optional[int]' = None, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, compliance_proofs: 'Optional[SwapProofs]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Prepare a direct or multi-hop swap; submit with transact/delegate. @@ -432,8 +432,10 @@ Journaled calls reserve a counter during preparation and retain the submitted handle. Without a journal, save the handle and avoid concurrent swaps; use an explicit identity for concurrency. ``track=False`` bypasses journaling. ``deadline_offset_blocks`` defaults to 10,000 (~8 hours). +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 0–1 bundles (the signer's, for a wrapped input). -### `claim_swap_output(self, handle: 'SwapHandle', *, timeout: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` +### `claim_swap_output(self, handle: 'SwapHandle', *, timeout: 'float' = 0.0, compliance_proofs: 'Optional[ClaimProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` Claim a private swap's output. @@ -449,6 +451,8 @@ the signer; claiming consumes the mapping entry. After swap confirmation, ``timeout`` sets the maximum wait in seconds (default 0). Missing output is polled every 2s without submitting. Timeout raises ``SwapOutputNotFinalizedError``; other errors propagate. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 1–3 bundles (AMM signer, then each wrapped leg paid). ### `create_pool(self, *, token0_id: 'str', token1_id: 'str', fee: 'int', initial_tick: 'int', tick_spacing: 'Optional[int]' = None, initial_sqrt_price: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` @@ -458,7 +462,7 @@ The fee tier must be registered with the program (validated before submission); tick spacing defaults to the tier's on-chain binding and the opening price to the tick's sqrt price. -### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, withdrawal: 'Optional[str]' = None, nonce: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` +### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, withdrawal: 'Optional[str]' = None, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[MintProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` Mint a concentrated-liquidity position as a private PositionNFT. @@ -467,23 +471,29 @@ from the slot's neighbors unless given explicitly. *withdrawal* is the immutable payout address stored on the NFT — ``collect`` always pays it and it can never be changed; defaults to *recipient*. Wrapped pool sides route via the LP router; fund with UNDERLYING records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 3–5 bundles (wrapped senders, then signer, recipient, withdrawal). -### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` +### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, compliance_proofs: 'Optional[IncreaseProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Add funds to an existing position (range fixed at mint). Wrapped pool sides route via the LP router; fund with UNDERLYING records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 0–2 bundles (the signer's, per wrapped side). ### `decrease_liquidity(self, *, pool_key: 'str', liquidity_to_remove: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Remove liquidity from a position; owed amounts become collectable. -### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', position_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` +### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', position_record: 'Optional[str]' = None, compliance_proofs: 'Optional[CollectProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Collect owed token amounts from a position. The payout always goes to the position's immutable ``withdrawal`` address — set at mint, not redirectable here. Wrapped pool sides route via the LP router, unwrapping to that address in-transaction. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 2–4 bundles (owner, withdrawal, then a receiver per wrapped side). ### `burn(self, *, pool_key: 'str', position_record: 'Optional[str]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` @@ -510,7 +520,7 @@ Raises: aligned range is empty, the sizing is ambiguous, or the budget supports no liquidity. -### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` +### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` Close a position and mint its successor range in ONE transaction. @@ -527,9 +537,13 @@ that moves the price between planning and finalize reverts the whole transaction (fee paid, no funds moved). Rebuild and resubmit when that happens; the short default deadline fails stale requests cheaply. Funding records for a wrapped side are the UNDERLYING asset's records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, +then owner and withdrawal). Raises: - ValueError: Without a plan or a complete (pool, range, sizing). + ValueError: Without a plan or a complete (pool, range, sizing), or + with the wrong number of compliance proof bundles. InsufficientRecordsError: If no record covers a funded side. ### `get_pool(self, pool_key: 'str') -> 'g.PoolState'` diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 50340f54..ece8fa84 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -176,6 +176,58 @@ Two conveniences trade secret material for service: `aleo.records.register` shares the account's **view key** with the scanning service, which can then see everything the account owns. +## Compliance proofs + +Shield Swap checks two kinds of freeze list. Its own +(`shield_swap_freezelist.aleo`) screens the signer, a position's recipient, +and its withdrawal address when a position is minted, collected, or +rebalanced, and the signer when a swap output is claimed. Each ARC-22 +stablecoin traded through a wrapper (USDCx, USAD) screens the signer when its +records are wrapped and the receiving address when they are unwrapped. Every +check is one `[MerkleProof; 2]` argument — a *bundle* — and a routed call +takes up to six. + +By default the SDK builds every bundle a call needs. It works out which +entrypoint the call resolves to and which address each bundle screens, reads +each list's current root, builds the bundles (the empty-tree pair while a +list is empty, a verified witness from the published tree otherwise), and +reuses them until the list's root changes. Nothing changes in how the +methods are called: + +```python +dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi, + amount0_desired=a0, amount1_desired=a1) # proofs built by the SDK +dex.claim_swap_output(handle) # likewise +``` + +An address that is on a list gets `FrozenAddressError` before any fee is +paid; an unreadable root or a published tree that does not match it raises +`FreezeListError` naming the fix. + +Pass `compliance_proofs=` to supply the bundles yourself, as a tuple in the +entrypoint's input order. Each element is the encoded literal, a +`MerkleProofPair`, or a `(MerkleProof, MerkleProof)` tuple. The count must +match the route the call resolves to, and the per-method aliases spell out +the possibilities: + +| Method | Type | Bundles, in order | +|---|---|---| +| `swap` | `SwapProofs` | 0–1: the signer's, when the input is wrapped. | +| `claim_swap_output` | `ClaimProofs` | 1–3: AMM signer, then a receiver bundle per wrapped leg paid (output, then refund). | +| `mint` | `MintProofs` | 3–5: a sender bundle per wrapped side (token0 first), then AMM signer, recipient, withdrawal. | +| `increase_liquidity` | `IncreaseProofs` | 0–2: a sender bundle per wrapped side. | +| `collect` | `CollectProofs` | 2–4: AMM owner, withdrawal, then a receiver bundle per wrapped side. | +| `rebalance_position` | `RebalanceProofs` | 2–6: a sender bundle per funded wrapped side, a receiver bundle per wrapped side, then AMM owner, withdrawal. | + +A wrong count raises `ValueError` listing the slots the route expects. +`wrapper_proofs=` — the earlier single-literal argument — still fills every +wrapped-side slot while the AMM slots are built by the SDK; it cannot be +combined with `compliance_proofs=`. + +`dex.freezelist` is the resolver (`aleo.FreezeListProofs`): +`dex.freezelist.exclusion_proof(address, program)` returns one bundle for any +address and list, and `dex.freezelist.clear()` forgets the cached trees. + ## Async `AsyncShieldSwap` / `AsyncApiClient` mirror the sync **read, swap and claim** diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 62707336..a9fad179 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -418,7 +418,7 @@ Returns the API's best route and final slippage floor; pass it to ``swap(quote)``. Requires API authentication. Quoting spends no funds and needs no record scanning or transaction signature. -### `swap(self, quote: 'Optional[SwapQuote]' = None, *, pool_key: 'Optional[str]' = None, token_in_id: 'Optional[str]' = None, amount_in: 'Optional[int | str | Decimal]' = None, slippage_bps: 'Optional[int]' = None, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, quote: 'Optional[SwapQuote]' = None, *, pool_key: 'Optional[str]' = None, token_in_id: 'Optional[str]' = None, amount_in: 'Optional[int | str | Decimal]' = None, slippage_bps: 'Optional[int]' = None, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, compliance_proofs: 'Optional[SwapProofs]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Prepare a direct or multi-hop swap; submit with transact/delegate. @@ -432,8 +432,10 @@ Journaled calls reserve a counter during preparation and retain the submitted handle. Without a journal, save the handle and avoid concurrent swaps; use an explicit identity for concurrency. ``track=False`` bypasses journaling. ``deadline_offset_blocks`` defaults to 10,000 (~8 hours). +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 0–1 bundles (the signer's, for a wrapped input). -### `claim_swap_output(self, handle: 'SwapHandle', *, timeout: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` +### `claim_swap_output(self, handle: 'SwapHandle', *, timeout: 'float' = 0.0, compliance_proofs: 'Optional[ClaimProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` Claim a private swap's output. @@ -449,6 +451,8 @@ the signer; claiming consumes the mapping entry. After swap confirmation, ``timeout`` sets the maximum wait in seconds (default 0). Missing output is polled every 2s without submitting. Timeout raises ``SwapOutputNotFinalizedError``; other errors propagate. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 1–3 bundles (AMM signer, then each wrapped leg paid). ### `create_pool(self, *, token0_id: 'str', token1_id: 'str', fee: 'int', initial_tick: 'int', tick_spacing: 'Optional[int]' = None, initial_sqrt_price: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` @@ -458,7 +462,7 @@ The fee tier must be registered with the program (validated before submission); tick spacing defaults to the tier's on-chain binding and the opening price to the tick's sqrt price. -### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, withdrawal: 'Optional[str]' = None, nonce: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` +### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, withdrawal: 'Optional[str]' = None, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[MintProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` Mint a concentrated-liquidity position as a private PositionNFT. @@ -467,23 +471,29 @@ from the slot's neighbors unless given explicitly. *withdrawal* is the immutable payout address stored on the NFT — ``collect`` always pays it and it can never be changed; defaults to *recipient*. Wrapped pool sides route via the LP router; fund with UNDERLYING records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 3–5 bundles (wrapped senders, then signer, recipient, withdrawal). -### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` +### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, compliance_proofs: 'Optional[IncreaseProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Add funds to an existing position (range fixed at mint). Wrapped pool sides route via the LP router; fund with UNDERLYING records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 0–2 bundles (the signer's, per wrapped side). ### `decrease_liquidity(self, *, pool_key: 'str', liquidity_to_remove: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Remove liquidity from a position; owed amounts become collectable. -### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', position_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` +### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', position_record: 'Optional[str]' = None, compliance_proofs: 'Optional[CollectProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` Collect owed token amounts from a position. The payout always goes to the position's immutable ``withdrawal`` address — set at mint, not redirectable here. Wrapped pool sides route via the LP router, unwrapping to that address in-transaction. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 2–4 bundles (owner, withdrawal, then a receiver per wrapped side). ### `burn(self, *, pool_key: 'str', position_record: 'Optional[str]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` @@ -510,7 +520,7 @@ Raises: aligned range is empty, the sizing is ambiguous, or the budget supports no liquidity. -### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` +### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` Close a position and mint its successor range in ONE transaction. @@ -527,9 +537,13 @@ that moves the price between planning and finalize reverts the whole transaction (fee paid, no funds moved). Rebuild and resubmit when that happens; the short default deadline fails stale requests cheaply. Funding records for a wrapped side are the UNDERLYING asset's records. +Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds +them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, +then owner and withdrawal). Raises: - ValueError: Without a plan or a complete (pool, range, sizing). + ValueError: Without a plan or a complete (pool, range, sizing), or + with the wrong number of compliance proof bundles. InsufficientRecordsError: If no record covers a funded side. ### `get_pool(self, pool_key: 'str') -> 'g.PoolState'` diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 7dad3571..e824b8be 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -22,6 +22,18 @@ RebalancePlan as RebalancePlan, RebalanceResult as RebalanceResult, ) +from .compliance import ( + ClaimProofs as ClaimProofs, + CollectProofs as CollectProofs, + IncreaseProofs as IncreaseProofs, + MerkleProof as MerkleProof, + MerkleProofPair as MerkleProofPair, + MintProofs as MintProofs, + ProofBundle as ProofBundle, + RebalanceProofs as RebalanceProofs, + SHIELD_SWAP_FREEZELIST as SHIELD_SWAP_FREEZELIST, + SwapProofs as SwapProofs, +) from .types import ( OwnedPosition as OwnedPosition, OwnedPositionState as OwnedPositionState, diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index eba273d7..db0b9824 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +from aleo.compliance import EMPTY_MERKLE_PROOF as _EMPTY_MERKLE_PROOF, EMPTY_MERKLE_PROOF_PAIR + import re import secrets from dataclasses import dataclass @@ -145,17 +147,17 @@ def generate_field_nonce() -> str: # # mint / claim_swap_output / collect prove signer (and recipient/withdrawal) # non-inclusion in the AMM freezelist; routed calls additionally carry -# wrapper-freezelist proofs. While a freezelist is empty, the contract -# accepts two copies of the empty-tree proof below (the credits wrapper -# ignores its proof input entirely). Building real proofs against a -# populated tree is deferred until a list is non-empty. +# wrapper-freezelist proofs. The clients resolve every bundle through +# ``aleo.compliance`` (see ``compliance.py``); the empty-tree pair below is +# what a list yields while it is empty, and what a wrapper that ignores its +# proof input (credits) receives. -EMPTY_MERKLE_PROOF = "{ siblings: [" + ", ".join(["0field"] * 16) + "], leaf_index: 1u32 }" +EMPTY_MERKLE_PROOF = _EMPTY_MERKLE_PROOF def default_merkle_proofs() -> str: """The ``[MerkleProof; 2]`` literal accepted while the freezelist is empty.""" - return f"[{EMPTY_MERKLE_PROOF}, {EMPTY_MERKLE_PROOF}]" + return EMPTY_MERKLE_PROOF_PAIR # ── Shared pure helpers (sync + async clients) ─────────────────────────────── diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index ce447eda..7e4c218c 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -16,6 +16,7 @@ from typing import Any, Callable, Generic, Optional, TypeVar from aleo import AleoNetworkError, ProgramNotFound +from aleo.compliance import AsyncFreezeListProofs, ProofSlot, async_freeze_list_program from . import _generated as g from ._calls import extract_tx_id, root_outputs @@ -37,6 +38,10 @@ unreadable_airdrop_transactions, ) from ._routing import claim_route, swap_route +from .compliance import ( + SHIELD_SWAP_FREEZELIST, ClaimProofs, Slot, SwapProofs, check_proof_arguments, claim_slots, + merge_legacy_wrapper_proofs, planned_proof_slots, supplied_literals, swap_slots, +) from .rebalance import RebalancePlan, plan_rebalance as _plan_rebalance from .position_math import ( amounts_for_liquidity, @@ -141,6 +146,10 @@ def __init__(self, aleo: Any, *, program: str = g.PROGRAM_ID, self.program = program # Resolve the API from the bound client's network (see api_url_for). self.api = AsyncApiClient(api_url or api_url_for(aleo.network_name)) + #: The freeze list Shield Swap itself checks; override for a non-standard deployment. + self.amm_freezelist_program: str = SHIELD_SWAP_FREEZELIST + #: Freeze-list exclusion proofs, cached per list until its root changes. + self.freezelist = AsyncFreezeListProofs(aleo) # allow_token relationships are immutable — cache probes for the # client's lifetime. self._wrapped_cache: dict[str, bool] = {} @@ -645,6 +654,41 @@ def entry_for(tok: Any) -> dict[str, Any]: # ── Writes ─────────────────────────────────────────────────────────────── + # ── Compliance proofs ──────────────────────────────────────────────────── + + async def _freezelist_for_token(self, token_id: str, program: Optional[str] = None) -> Optional[str]: + """The freeze list a wrapped token's wrapper checks — its underlying + stablecoin's — or None for a wrapper that ignores the proof (credits). + *program* is the underlying program when the caller named it.""" + if program is None: + try: + program = await self._token_program(token_id) + except ValueError as exc: + raise ValueError( + f"Cannot resolve the program behind {token_id} to find its freeze " + "list — pass compliance_proofs= explicitly.") from exc + return await async_freeze_list_program(self._aleo, program) + + async def _compliance_proofs(self, action: str, slots: list[Slot], compliance_proofs: Any, + wrapper_proofs: Optional[str], + programs: Optional[dict[str, Optional[str]]] = None) -> list[str]: + """The ``[MerkleProof; 2]`` literal for every planned slot, in order + (see the sync client).""" + check_proof_arguments(compliance_proofs, wrapper_proofs) + if compliance_proofs is not None: + return supplied_literals(action, compliance_proofs, slots) + if wrapper_proofs is not None: + amm_only = [ProofSlot(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None + for slot in slots] + return merge_legacy_wrapper_proofs(slots, await self.freezelist.resolve(amm_only), wrapper_proofs) + known = programs or {} + lists: dict[str, Optional[str]] = {} + for slot in slots: + if slot.kind == "wrapper" and slot.token_id is not None and slot.token_id not in lists: + lists[slot.token_id] = await self._freezelist_for_token(slot.token_id, known.get(slot.token_id)) + planned = planned_proof_slots(slots, self.amm_freezelist_program, lambda token_id: lists[token_id]) + return await self.freezelist.resolve(planned) + async def quote(self, *, token_in: str, token_out: str, amount_in: str | Decimal, slippage_bps: int = 50) -> SwapQuote: """Quote a direct or multi-hop swap from two symbols and a token amount. @@ -666,7 +710,8 @@ async def swap(self, quote: Optional[SwapQuote] = None, *, pool_key: Optional[st nonce: Optional[int] = None, token_in_program: Optional[str] = None, token_record: Optional[str] = None, - identity: Optional[BlindedIdentity] = None, + identity: Optional[BlindedIdentity] = None, + compliance_proofs: Optional[SwapProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None) -> AsyncDexCall[SwapHandle]: @@ -731,8 +776,9 @@ async def swap(self, quote: Optional[SwapQuote] = None, *, pool_key: Optional[st resolved, hop_literals = resolve_quote(quote, pools, slots, amount_in, expected_out) record = token_record + program = token_in_program if record is None: - program = token_in_program or await self._token_program(token_in_id) + program = program or await self._token_program(token_in_id) record = await self._select_token_record( program=program, min_amount=amount_in, token_id=token_in_id, account=acct) @@ -756,6 +802,10 @@ async def swap(self, quote: Optional[SwapQuote] = None, *, pool_key: Optional[st token_programs.append(await self._amm_token_program(hop.token_in)) token_programs.append(await self._amm_token_program(hop.token_out)) await self._ensure([p for p in token_programs if p], imports) + proofs = await self._compliance_proofs( + "swap", swap_slots(route.program != self.program, str(acct.address), token_in_id), + compliance_proofs, wrapper_proofs, {token_in_id: program}) + sender_proof = proofs[0] if proofs else default_merkle_proofs() inputs = [ record, identity.blinding_factor, identity.blinded_address, @@ -770,12 +820,11 @@ async def swap(self, quote: Optional[SwapQuote] = None, *, pool_key: Optional[st raise ValueError( "routed swaps require amount_out_min > 0 — pass " "expected_out or a slippage below 100%") - inputs = [record, wrapper_proofs or default_merkle_proofs(), - *inputs[1:]] + inputs = [record, sender_proof, *inputs[1:]] if multi_hop: inputs = multi_hop_inputs(quote, hop_literals, record, identity, amount_in, resolved.amount_out_min, swap_nonce, deadline, - wrapper_proofs or default_merkle_proofs(), route.program != self.program) + sender_proof, route.program != self.program) prog = await self._program(route.program) bound = getattr(prog.functions, route.function)(*inputs) @@ -801,6 +850,7 @@ def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: async def claim_swap_output(self, handle: SwapHandle, *, timeout: float = 0.0, + compliance_proofs: Optional[ClaimProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None) -> AsyncDexCall[ClaimResult]: @@ -821,7 +871,7 @@ async def claim_swap_output(self, handle: SwapHandle, *, """ if not math.isfinite(timeout) or timeout < 0: raise ValueError("timeout must be finite and nonnegative") - self._account(account) + acct = self._account(account) if not handle.swap_id: raise ValueError("handle.swap_id is not set — recover it from the " "confirmed request transaction before claiming.") @@ -859,12 +909,12 @@ async def claim_swap_output(self, handle: SwapHandle, *, out.token_in, out.token_out, f"{out.amount_out}u128"] if not no_refund: inputs.append(f"{out.amount_remaining}u128") - inputs.append(default_merkle_proofs()) - wp = wrapper_proofs or default_merkle_proofs() - if w_out: - inputs.append(wp) # output receiver proof - if w_in and not no_refund: - inputs.append(wp) # refund receiver proof + # The AMM signer bundle, then one receiver bundle per wrapped leg being + # paid — the output first, then the refund (none on a no-refund claim). + inputs.extend(await self._compliance_proofs( + "claim_swap_output", + claim_slots(w_out, w_in, no_refund, str(acct.address), str(out.token_in), str(out.token_out)), + compliance_proofs, wrapper_proofs)) prog = await self._program(route.program) bound = getattr(prog.functions, route.function)(*inputs) return AsyncDexCall(self._aleo, bound, diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 52400070..f15de301 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -17,6 +17,7 @@ from aleo import AleoNetworkError, ProgramNotFound from aleo.codegen.runtime import parse_plaintext +from aleo.compliance import FreezeListProofs, ProofSlot, freeze_list_program from . import _generated as g from ._core import ( @@ -84,6 +85,11 @@ TxResult, ) from ._calls import DexCall +from .compliance import ( + SHIELD_SWAP_FREEZELIST, ClaimProofs, CollectProofs, IncreaseProofs, MintProofs, RebalanceProofs, Slot, + SwapProofs, check_proof_arguments, claim_slots, collect_slots, increase_slots, merge_legacy_wrapper_proofs, + mint_slots, planned_proof_slots, rebalance_slots, supplied_literals, swap_slots, +) from ._routing import ( claim_route, collect_route, @@ -134,6 +140,10 @@ def __init__(self, aleo: Any, *, program: str = g.PROGRAM_ID, # client's lifetime. self._wrapped_cache: dict[str, bool] = {} self._program_handles: dict[str, Any] = {} + #: The freeze list Shield Swap itself checks; override for a non-standard deployment. + self.amm_freezelist_program: str = SHIELD_SWAP_FREEZELIST + #: Freeze-list exclusion proofs, cached per list until its root changes. + self.freezelist = FreezeListProofs(aleo) def __repr__(self) -> str: return f"ShieldSwap(program={self.program!r}, api={self.api.base_url!r})" @@ -813,6 +823,43 @@ def _lp_programs(self, route: Any, program0: Optional[str], pids.append(wrapper) return pids + # ── Compliance proofs ──────────────────────────────────────────────────── + + def _freezelist_for_token(self, token_id: str, program: Optional[str] = None) -> Optional[str]: + """The freeze list a wrapped token's wrapper checks — its underlying + stablecoin's — or None for a wrapper that ignores the proof (credits). + *program* is the underlying program when the caller named it.""" + if program is None: + try: + program = self._token_program(token_id) + except ValueError as exc: + raise ValueError( + f"Cannot resolve the program behind {token_id} to find its freeze " + "list — pass compliance_proofs= explicitly.") from exc + return freeze_list_program(self._aleo, program) + + def _compliance_proofs(self, action: str, slots: list[Slot], compliance_proofs: Any, + wrapper_proofs: Optional[str], + programs: Optional[dict[str, Optional[str]]] = None) -> list[str]: + """The ``[MerkleProof; 2]`` literal for every planned slot, in order: + the caller's bundles when given (count-checked against the route), + otherwise built from each list's current root and cached. *programs* + maps a token id to the underlying program the caller named for it.""" + check_proof_arguments(compliance_proofs, wrapper_proofs) + if compliance_proofs is not None: + return supplied_literals(action, compliance_proofs, slots) + if wrapper_proofs is not None: + # Legacy form: the caller's one literal fills every wrapper slot; + # only the AMM slots are resolved. + amm_only = [ProofSlot(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None + for slot in slots] + return merge_legacy_wrapper_proofs(slots, self.freezelist.resolve(amm_only), wrapper_proofs) + known = programs or {} + planned = planned_proof_slots( + slots, self.amm_freezelist_program, + lambda token_id: self._freezelist_for_token(token_id, known.get(token_id))) + return self.freezelist.resolve(planned) + def quote(self, *, token_in: str, token_out: str, amount_in: str | Decimal, slippage_bps: int = 50) -> SwapQuote: """Quote a direct or multi-hop swap from two symbols and a token amount. @@ -841,6 +888,7 @@ def swap( identity: Optional[BlindedIdentity] = None, token_in_program: Optional[str] = None, token_record: Optional[str] = None, + compliance_proofs: Optional[SwapProofs] = None, wrapper_proofs: Optional[str] = None, track: bool = True, imports: Optional[dict[str, str]] = None, @@ -858,6 +906,8 @@ def swap( submitted handle. Without a journal, save the handle and avoid concurrent swaps; use an explicit identity for concurrency. ``track=False`` bypasses journaling. ``deadline_offset_blocks`` defaults to 10,000 (~8 hours). + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 0–1 bundles (the signer's, for a wrapped input). """ if quote is not None: if any(v is not None for v in (pool_key, token_in_id, amount_in, expected_out, slippage_bps, sqrt_price_limit)): @@ -927,6 +977,14 @@ def swap( for hop in quote.hops: extra.extend([self._amm_token_program(hop.token_in), self._amm_token_program(hop.token_out)]) self._ensure([p for p in (program, wrapper, *extra) if p], imports) + if route.program != self.program and resolved.amount_out_min <= 0: + raise ValueError( + "routed swaps require amount_out_min > 0 — pass " + "expected_out or a slippage below 100%") + proofs = self._compliance_proofs( + "swap", swap_slots(route.program != self.program, str(acct.address), token_in_id), + compliance_proofs, wrapper_proofs, {token_in_id: program}) + sender_proof = proofs[0] if proofs else default_merkle_proofs() # Core input order per the contract's swap entrypoint: # record, blinding slots, then pool/direction/amounts/bounds/timing/tokens. @@ -947,16 +1005,11 @@ def swap( if route.program != self.program: # Router deposits the underlying, swaps, and burns the empty # wrapper change record in one transaction. - if resolved.amount_out_min <= 0: - raise ValueError( - "routed swaps require amount_out_min > 0 — pass " - "expected_out or a slippage below 100%") - inputs = [record, wrapper_proofs or default_merkle_proofs(), - *inputs[1:]] + inputs = [record, sender_proof, *inputs[1:]] if multi_hop: inputs = multi_hop_inputs(quote, hop_literals, record, identity, amount_in, resolved.amount_out_min, swap_nonce, deadline, - wrapper_proofs or default_merkle_proofs(), route.program != self.program) + sender_proof, route.program != self.program) bound = getattr(self._program(route.program).functions, route.function)(*inputs) @@ -999,6 +1052,7 @@ def claim_swap_output( handle: SwapHandle, *, timeout: float = 0.0, + compliance_proofs: Optional[ClaimProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None, @@ -1017,10 +1071,12 @@ def claim_swap_output( After swap confirmation, ``timeout`` sets the maximum wait in seconds (default 0). Missing output is polled every 2s without submitting. Timeout raises ``SwapOutputNotFinalizedError``; other errors propagate. + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 1–3 bundles (AMM signer, then each wrapped leg paid). """ if not math.isfinite(timeout) or timeout < 0: raise ValueError("timeout must be finite and nonnegative") - self._account(account) + acct = self._account(account) if not handle.swap_id: raise ValueError( "handle.swap_id is not set — recover it from the confirmed " @@ -1071,14 +1127,12 @@ def claim_swap_output( ] if not no_refund: inputs.append(f"{out.amount_remaining}u128") - inputs.append(default_merkle_proofs()) - # Wrapper receiver proofs: one per wrapped leg being paid — the output - # first, then the refund. A no-refund claim pays only the output. - wp = wrapper_proofs or default_merkle_proofs() - if w_out: - inputs.append(wp) - if w_in and not no_refund: - inputs.append(wp) + # The AMM signer bundle, then one receiver bundle per wrapped leg being + # paid — the output first, then the refund (none on a no-refund claim). + inputs.extend(self._compliance_proofs( + "claim_swap_output", + claim_slots(w_out, w_in, no_refund, str(acct.address), str(out.token_in), str(out.token_out)), + compliance_proofs, wrapper_proofs)) bound = getattr(self._program(route.program).functions, route.function)(*inputs) @@ -1552,6 +1606,7 @@ def rebalance_position( tick_upper_hint: Optional[int] = None, deadline_offset_blocks: int = REBALANCE_DEADLINE_OFFSET_BLOCKS, nonce: Optional[str] = None, + compliance_proofs: Optional[RebalanceProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None, @@ -1571,9 +1626,13 @@ def rebalance_position( transaction (fee paid, no funds moved). Rebuild and resubmit when that happens; the short default deadline fails stale requests cheaply. Funding records for a wrapped side are the UNDERLYING asset's records. + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, + then owner and withdrawal). Raises: - ValueError: Without a plan or a complete (pool, range, sizing). + ValueError: Without a plan or a complete (pool, range, sizing), or + with the wrong number of compliance proof bundles. InsufficientRecordsError: If no record covers a funded side. """ acct = self._account(account) @@ -1656,7 +1715,12 @@ def survivor(hint: int) -> int: # Slot rule shared by all 14 entries: each funded side's record (with # the wrapped side's sender proof right after it), then every wrapped # side's receiver proof, then the common tail. - wp = wrapper_proofs or default_merkle_proofs() + proofs = iter(self._compliance_proofs( + "rebalance_position", + rebalance_slots(w0, w1, plan.funded0 > 0, plan.funded1 > 0, str(acct.address), + str(decoded["withdrawal"]), str(pool.token0), str(pool.token1)), + compliance_proofs, wrapper_proofs, + {str(pool.token0): token0_program, str(pool.token1): token1_program})) programs: list[str] = [route.program] funding: list[str] = [] sides = ((pool.token0, w0, plan.funded0, token0_record, token0_program), @@ -1670,8 +1734,8 @@ def survivor(hint: int) -> int: programs.append(program) funding.append(record) if wrapped: - funding.append(wp) - receivers = [wp for wrapped in (w0, w1) if wrapped] + funding.append(next(proofs)) + receivers = [next(proofs) for wrapped in (w0, w1) if wrapped] # Refunds pay through both settlement token programs; wrapped sides # also dispatch into their wrapper. for token_id, wrapped in ((pool.token0, w0), (pool.token1, w1)): @@ -1686,9 +1750,8 @@ def survivor(hint: int) -> int: self._ensure(programs, imports) field_nonce = nonce if nonce is not None else generate_field_nonce() - proofs = default_merkle_proofs() inputs = [position, field_nonce, *funding, *receivers, - request, assets, proofs, proofs] + request, assets, *proofs] bound = getattr(self._program(route.program).functions, route.function)(*inputs) final_plan = plan @@ -1739,6 +1802,7 @@ def mint( recipient: Optional[str] = None, withdrawal: Optional[str] = None, nonce: Optional[str] = None, + compliance_proofs: Optional[MintProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None, @@ -1750,6 +1814,8 @@ def mint( the immutable payout address stored on the NFT — ``collect`` always pays it and it can never be changed; defaults to *recipient*. Wrapped pool sides route via the LP router; fund with UNDERLYING records. + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 3–5 bundles (wrapped senders, then signer, recipient, withdrawal). """ acct = self._account(account) @@ -1795,15 +1861,16 @@ def mint( field_nonce = nonce if nonce is not None else generate_field_nonce() to = recipient or str(acct.address) payout = withdrawal or to - proofs = default_merkle_proofs() - wp = wrapper_proofs or default_merkle_proofs() + proofs = iter(self._compliance_proofs( + "mint", mint_slots(w0, w1, str(acct.address), to, payout, str(pool.token0), str(pool.token1)), + compliance_proofs, wrapper_proofs, {str(pool.token0): program0, str(pool.token1): program1})) # Wrapper proofs sit directly after their wrapped-side record - # (deployed LP-router input order). - rec0 = [record0, wp] if w0 else [record0] - rec1 = [record1, wp] if w1 else [record1] + # (deployed LP-router input order); the three AMM bundles close the call. + rec0 = [record0, next(proofs)] if w0 else [record0] + rec1 = [record1, next(proofs)] if w1 else [record1] inputs = [field_nonce, *rec0, *rec1, to, payout, request, - pool.token0, pool.token1, proofs, proofs, proofs] + pool.token0, pool.token1, *proofs] bound = getattr(self._program(route.program).functions, route.function)(*inputs) @@ -1839,12 +1906,16 @@ def increase_liquidity( position_record: Optional[str] = None, tick_lower_hint: Optional[int] = None, tick_upper_hint: Optional[int] = None, + compliance_proofs: Optional[IncreaseProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None, ) -> DexCall[TxResult]: """Add funds to an existing position (range fixed at mint). - Wrapped pool sides route via the LP router; fund with UNDERLYING records.""" + Wrapped pool sides route via the LP router; fund with UNDERLYING records. + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 0–2 bundles (the signer's, per wrapped side). + """ acct = self._account(account) pool = self.get_pool(pool_key) @@ -1874,9 +1945,11 @@ def increase_liquidity( self._ensure(self._lp_programs(route, program0, program1, pool, w0, w1), imports) - wp = wrapper_proofs or default_merkle_proofs() - rec0 = [record0, wp] if w0 else [record0] - rec1 = [record1, wp] if w1 else [record1] + proofs = iter(self._compliance_proofs( + "increase_liquidity", increase_slots(w0, w1, str(acct.address), str(pool.token0), str(pool.token1)), + compliance_proofs, wrapper_proofs, {str(pool.token0): program0, str(pool.token1): program1})) + rec0 = [record0, next(proofs)] if w0 else [record0] + rec1 = [record1, next(proofs)] if w1 else [record1] inputs = [ position, *rec0, *rec1, f"{amount0_desired}u128", f"{amount1_desired}u128", @@ -1915,6 +1988,7 @@ def collect( amount0_requested: int, amount1_requested: int, position_record: Optional[str] = None, + compliance_proofs: Optional[CollectProofs] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None, @@ -1924,6 +1998,8 @@ def collect( The payout always goes to the position's immutable ``withdrawal`` address — set at mint, not redirectable here. Wrapped pool sides route via the LP router, unwrapping to that address in-transaction. + Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds + them, or pass 2–4 bundles (owner, withdrawal, then a receiver per wrapped side). """ acct = self._account(account) pool = self.get_pool(pool_key) @@ -1944,14 +2020,12 @@ def collect( except ValueError: pass self._ensure(token_programs, imports) - proofs = default_merkle_proofs() - wp = wrapper_proofs or default_merkle_proofs() + withdrawal = str(parse_plaintext(position)["withdrawal"]) + proofs = self._compliance_proofs( + "collect", collect_slots(w0, w1, str(acct.address), withdrawal, str(pool.token0), str(pool.token1)), + compliance_proofs, wrapper_proofs) inputs = [position, f"{amount0_requested}u128", f"{amount1_requested}u128", - pool.token0, pool.token1, proofs, proofs] - if w0: - inputs.append(wp) - if w1: - inputs.append(wp) + pool.token0, pool.token1, *proofs] bound = getattr(self._program(route.program).functions, route.function)(*inputs) # collect's first output is the re-issued PositionNFT record, not a diff --git a/shield-swap-sdk/python/aleo_shield_swap/compliance.py b/shield-swap-sdk/python/aleo_shield_swap/compliance.py new file mode 100644 index 00000000..3be4c455 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/compliance.py @@ -0,0 +1,233 @@ +"""Compliance proofs for Shield Swap calls. + +Shield Swap checks two kinds of freeze list. Its own +(``shield_swap_freezelist.aleo``) screens the signer, a position's recipient, +and its withdrawal address when a position is minted, collected, or +rebalanced, and the signer when a swap output is claimed. Each ARC-22 +stablecoin that the AMM trades through a wrapper (USDCx, USAD) screens the +signer when its records are wrapped and the receiving address when they are +unwrapped. Every check is one ``[MerkleProof; 2]`` argument — a *bundle* — +and the routers take up to six per call. + +The write methods on :class:`~aleo_shield_swap.ShieldSwap` take one +``compliance_proofs`` argument: + +- ``None`` (the default): the SDK works out which bundles the selected + entrypoint needs and which address each one screens, reads each list's + current root, and builds the bundles — the empty-tree pair while a list is + empty, a verified witness from the published tree otherwise. Witnesses are + cached on the client until the list's root changes. +- A tuple of bundles in the entrypoint's input order, each a literal string, + a :class:`~aleo.compliance.MerkleProofPair`, or a pair of + :class:`~aleo.compliance.MerkleProof`. The count must match the route the + call resolves to; the type aliases below spell out the possible counts. + +The planners in this module are pure: they turn a route's shape into the +ordered list of :class:`Slot` values a call must fill, following +``MerkleProofReference.md`` in the contract repository. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Literal, Optional, Sequence, Union + +from aleo.compliance import ( + EMPTY_MERKLE_PROOF_PAIR, + FreezeListError, + MerkleProof, + MerkleProofPair, + ProofBundle, + ProofSlot, + bundle_literal, +) + +SHIELD_SWAP_FREEZELIST = "shield_swap_freezelist.aleo" + +_B = ProofBundle +#: ``swap``: one sender bundle when the input token is wrapped, none otherwise. +SwapProofs = Union[tuple[()], tuple[_B]] +#: ``claim_swap_output``: the AMM signer bundle, then a receiver bundle per wrapped leg paid (output, then refund). +ClaimProofs = Union[tuple[_B], tuple[_B, _B], tuple[_B, _B, _B]] +#: ``mint``: a sender bundle per wrapped side (token0 first), then AMM signer, recipient, withdrawal. +MintProofs = Union[tuple[_B, _B, _B], tuple[_B, _B, _B, _B], tuple[_B, _B, _B, _B, _B]] +#: ``increase_liquidity``: a sender bundle per wrapped side, token0 first. +IncreaseProofs = Union[tuple[()], tuple[_B], tuple[_B, _B]] +#: ``collect``: AMM owner and withdrawal bundles, then a receiver bundle per wrapped side. +CollectProofs = Union[tuple[_B, _B], tuple[_B, _B, _B], tuple[_B, _B, _B, _B]] +#: ``rebalance_position``: a sender bundle per funded wrapped side, a receiver bundle per wrapped side, then AMM owner and withdrawal. +RebalanceProofs = Union[tuple[_B, _B], tuple[_B, _B, _B], tuple[_B, _B, _B, _B], + tuple[_B, _B, _B, _B, _B], tuple[_B, _B, _B, _B, _B, _B]] + +SlotKind = Literal["amm", "wrapper"] + + +@dataclass(frozen=True) +class Slot: + """One ``[MerkleProof; 2]`` argument of a Shield Swap entrypoint. + + Attributes: + name: The entrypoint's parameter name (``sender_proof0``, + ``amm_withdrawal_merkle_proofs``), used in error messages. + kind: ``"amm"`` for Shield Swap's own list, ``"wrapper"`` for the + underlying stablecoin's list of a wrapped token. + subject: The address the argument proves absent. + token_id: The wrapped token whose underlying list applies; ``None`` + for AMM slots. + """ + + name: str + kind: SlotKind + subject: str + token_id: Optional[str] = None + + +def _amm(name: str, subject: str) -> Slot: + return Slot(name, "amm", subject) + + +def _wrapper(name: str, subject: str, token_id: str) -> Slot: + return Slot(name, "wrapper", subject, str(token_id)) + + +# ── Planners (one per entrypoint family; order = the deployed input order) ── + +def swap_slots(input_wrapped: bool, signer: str, token_in_id: str) -> list[Slot]: + """``swap`` / ``swap_multi_hop`` and their ``*_from_wrapped`` router forms.""" + return [_wrapper("sender_proof", signer, token_in_id)] if input_wrapped else [] + + +def claim_slots(output_wrapped: bool, refund_wrapped: bool, no_refund: bool, + signer: str, token_in_id: str, token_out_id: str) -> list[Slot]: + """``claim_swap_output`` and the router's ``claim_to_*`` forms: the signer receives everything.""" + slots = [_amm("merkle_proofs", signer)] + if output_wrapped: + slots.append(_wrapper("out_receiver_proof" if (refund_wrapped and not no_refund) else "receiver_proof", + signer, token_out_id)) + if refund_wrapped and not no_refund: + slots.append(_wrapper("refund_receiver_proof" if output_wrapped else "receiver_proof", signer, token_in_id)) + return slots + + +def mint_slots(w0: bool, w1: bool, signer: str, recipient: str, withdrawal: str, + token0_id: str, token1_id: str) -> list[Slot]: + """``mint`` and ``mint_from_*``: wrapped senders (token0, token1), then the three AMM roles.""" + slots: list[Slot] = [] + if w0: + slots.append(_wrapper("sender_proof0", signer, token0_id)) + if w1: + slots.append(_wrapper("sender_proof1", signer, token1_id)) + prefix = "amm_" if (w0 or w1) else "" + slots += [_amm(f"{prefix}signer_merkle_proofs", signer), + _amm(f"{prefix}recipient_merkle_proofs", recipient), + _amm(f"{prefix}withdrawal_merkle_proofs", withdrawal)] + return slots + + +def increase_slots(w0: bool, w1: bool, signer: str, token0_id: str, token1_id: str) -> list[Slot]: + """``increase_liquidity`` and ``increase_from_*``: one sender bundle per wrapped side.""" + slots: list[Slot] = [] + if w0: + slots.append(_wrapper("sender_proof0", signer, token0_id)) + if w1: + slots.append(_wrapper("sender_proof1", signer, token1_id)) + return slots + + +def collect_slots(w0: bool, w1: bool, signer: str, withdrawal: str, + token0_id: str, token1_id: str) -> list[Slot]: + """``collect`` and ``collect_to_*``: AMM owner and withdrawal, then a receiver per wrapped side. + + Receiver bundles screen the position's immutable withdrawal address, not + the signer: that is who the unwrapped tokens are paid to. + """ + prefix = "amm_" if (w0 or w1) else "" + slots = [_amm(f"{prefix}owner_merkle_proofs", signer), _amm(f"{prefix}withdrawal_merkle_proofs", withdrawal)] + if w0: + slots.append(_wrapper("receiver_proof0", withdrawal, token0_id)) + if w1: + slots.append(_wrapper("receiver_proof1", withdrawal, token1_id)) + return slots + + +def rebalance_slots(w0: bool, w1: bool, funded0: bool, funded1: bool, signer: str, withdrawal: str, + token0_id: str, token1_id: str) -> list[Slot]: + """``rebalance___``: funded wrapped senders, wrapped receivers, then AMM owner and withdrawal. + + A sender bundle follows the funding record of each wrapped side that + takes funding; receiver bundles screen the withdrawal address against + every wrapped side's list, refund or not. + """ + slots: list[Slot] = [] + one_sided = (w0 == w1) and (funded0 != funded1) # the router's ``*_one`` form names a single ``sender_proof`` + if funded0 and w0: + slots.append(_wrapper("sender_proof" if one_sided else "sender_proof0", signer, token0_id)) + if funded1 and w1: + slots.append(_wrapper("sender_proof" if one_sided else "sender_proof1", signer, token1_id)) + if w0: + slots.append(_wrapper("receiver_proof0", withdrawal, token0_id)) + if w1: + slots.append(_wrapper("receiver_proof1", withdrawal, token1_id)) + slots += [_amm("owner_proofs", signer), _amm("withdrawal_proofs", withdrawal)] + return slots + + +# ── Filling the slots ──────────────────────────────────────────────────────── + +ListResolver = Callable[[str], Optional[str]] +"""Maps a wrapped token id to the freeze-list program its wrapper checks (``None`` when it ignores the proof).""" + + +def supplied_literals(action: str, supplied: Sequence[ProofBundle], slots: Sequence[Slot]) -> list[str]: + """Validate caller-supplied bundles against the planned slots and return their literals in order. + + Raises: + ValueError: The count does not match the entrypoint's slots (the + message lists them), or a value is not a proof bundle. + """ + if _looks_like_one_bundle(supplied): + supplied = (supplied,) # type: ignore[assignment] + if len(supplied) != len(slots): + expected = ", ".join(f"{s.name} ({s.kind}: {s.subject[:10]}…)" for s in slots) or "none" + raise ValueError(f"{action} on this route takes {len(slots)} compliance proof bundle(s) " + f"[{expected}]; received {len(supplied)}") + try: + return [bundle_literal(b) for b in supplied] + except FreezeListError as exc: + raise ValueError(f"{action}: {exc}") from exc + + +def _looks_like_one_bundle(value: Sequence[ProofBundle]) -> bool: + return len(value) == 2 and all(isinstance(p, MerkleProof) for p in value) + + +def planned_proof_slots(slots: Sequence[Slot], amm_list: Optional[str], list_for_token: ListResolver) -> list[ProofSlot]: + """The :class:`aleo.compliance.ProofSlot` per planned slot: which list and which address.""" + out: list[ProofSlot] = [] + for slot in slots: + if slot.kind == "amm": + out.append(ProofSlot(amm_list, slot.subject)) + else: + assert slot.token_id is not None + out.append(ProofSlot(list_for_token(slot.token_id), slot.subject)) + return out + + +def merge_legacy_wrapper_proofs(slots: Sequence[Slot], resolved: Sequence[str], wrapper_proofs: str) -> list[str]: + """Put the caller's one legacy ``wrapper_proofs`` literal into every wrapper slot, keeping resolved AMM bundles.""" + literal = bundle_literal(wrapper_proofs) + return [literal if slot.kind == "wrapper" else proof for slot, proof in zip(slots, resolved)] + + +def check_proof_arguments(compliance_proofs: object, wrapper_proofs: Optional[str]) -> None: + """Reject ``compliance_proofs`` and the legacy ``wrapper_proofs`` given together.""" + if compliance_proofs is not None and wrapper_proofs is not None: + raise ValueError("Pass compliance_proofs= or wrapper_proofs=, not both") + + +__all__ = [ + "EMPTY_MERKLE_PROOF_PAIR", "SHIELD_SWAP_FREEZELIST", + "ClaimProofs", "CollectProofs", "IncreaseProofs", "MintProofs", "RebalanceProofs", "SwapProofs", + "MerkleProof", "MerkleProofPair", "ProofBundle", "Slot", + "check_proof_arguments", "claim_slots", "collect_slots", "increase_slots", "merge_legacy_wrapper_proofs", + "mint_slots", "planned_proof_slots", "rebalance_slots", "supplied_literals", "swap_slots", +] diff --git a/shield-swap-sdk/tests/conftest.py b/shield-swap-sdk/tests/conftest.py index 4caab039..fb517b8b 100644 --- a/shield-swap-sdk/tests/conftest.py +++ b/shield-swap-sdk/tests/conftest.py @@ -138,6 +138,11 @@ def __init__(self, recorder, mappings, pid): self._mappings = mappings self.functions = _Functions(recorder, pid) self.source = _valid_source(pid) + # Every token program the stub does not describe is backed by an + # (empty) freeze list named after it, so the automatic compliance + # path resolves without a registry; a test overrides ``imports``. + default = [] if pid.endswith("freezelist.aleo") else [f"{pid.removesuffix('.aleo')}_freezelist.aleo"] + self.imports = list(recorder.imports.get(pid, default)) def mapping(self, name): return _Mapping(self._mappings.get(name, {})) @@ -187,14 +192,25 @@ def get_transaction_object(self, tx_id): def get_program_mapping_value(self, program_id, mapping_name, key): """The node's mapping endpoint: no program handle involved. The - stub keys mappings by name only; a program listed in - ``missing_programs`` answers the node's 404.""" + stub keys mappings by name only — except ``freeze_list_root``, which + is keyed by program (``StubAleo.roots``) because several lists are + read in one call; a program listed in ``missing_programs`` answers + the node's 404.""" self._recorder.mapping_reads.append((program_id, mapping_name, key)) if program_id in self._recorder.missing_programs: from aleo import AleoNetworkError raise AleoNetworkError(f"GET /program/{program_id}/... returned 404", status=404) + if mapping_name == "freeze_list_root": + from aleo.compliance import EMPTY_TREE_ROOT + root = self._recorder.roots.get(program_id, EMPTY_TREE_ROOT if program_id.endswith("freezelist.aleo") else None) + return None if root is None else f"{root}field" return self._recorder._mappings.get(mapping_name, {}).get(key) + def get_program_freeze_list(self, program_id): + """The compliance endpoint: ``StubAleo.freeze_list_trees[program]`` (None = no list).""" + self._recorder.tree_reads.append(program_id) + return self._recorder.freeze_list_trees.get(program_id) + class _Provider: def __init__(self, records): @@ -219,7 +235,17 @@ def __init__(self, mappings=None, records=None): self.fetched_programs = [] self.registered_programs = [] self.mapping_reads = [] + self.tree_reads = [] self.missing_programs = set() + # Freeze lists: every list the stub knows is empty unless a test says + # otherwise — ``roots[program]`` is the current root (int), and + # ``freeze_list_trees[program]`` the published tree for a populated one. + # (``roots[program] = None`` makes that list's root unreadable.) + self.roots = {} + self.freeze_list_trees = {} + # program id -> import list, for the compliance resolver's freeze-list lookup. + self.imports = {"test_usdcx_stablecoin.aleo": ["test_usdcx_freezelist.aleo"], + "test_usad_stablecoin.aleo": ["test_usad_freezelist.aleo"]} self._mappings = mappings or {} self.programs = _Programs(self, self._mappings) self.record_provider = _Provider(records if records is not None diff --git a/shield-swap-sdk/tests/integration/test_compliance_live.py b/shield-swap-sdk/tests/integration/test_compliance_live.py new file mode 100644 index 00000000..4a0d07fa --- /dev/null +++ b/shield-swap-sdk/tests/integration/test_compliance_live.py @@ -0,0 +1,107 @@ +"""Freeze-list proofs against the REAL testnet: both lists Shield Swap checks +(its own and the USDCx stablecoin's), the published trees, and the SDK's +automatic resolution through ``dex.freezelist``. Read-only; the write +lifecycles (``test_swap_lifecycle.py``, ``test_liquidity_lifecycle_live.py``) +exercise the same resolver on every swap, claim, mint, increase, rebalance +and collect they submit.""" +from __future__ import annotations + +import pytest + +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, ProofSlot, exclusion_proof_from_tree +from aleo_shield_swap.compliance import SHIELD_SWAP_FREEZELIST + +pytestmark = pytest.mark.live + +USDCX_LIST = "test_usdcx_freezelist.aleo" +ANY_ADDRESS = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" + + +@pytest.mark.parametrize("program", [SHIELD_SWAP_FREEZELIST, USDCX_LIST]) +def test_published_tree_matches_the_on_chain_root(live_dex, program): + aleo = live_dex._aleo + tree = aleo.network.get_program_freeze_list(program) + assert tree is not None and len(tree) >= 3 and (len(tree) + 1) % 2 == 0 + root = live_dex.freezelist.root(program) + assert tree[-1] == root + # A witness built from the published tree reconstructs that root. + proof = exclusion_proof_from_tree(tree, ANY_ADDRESS, root, "testnet") + assert proof.startswith("[{ siblings: [") and proof.count("leaf_index") == 2 + if root == EMPTY_TREE_ROOT: + assert proof == EMPTY_MERKLE_PROOF_PAIR + + +def test_programs_without_a_list_answer_none(live_dex): + assert live_dex._aleo.network.get_program_freeze_list("credits.aleo") is None + + +def test_resolver_finds_the_list_behind_each_token_and_caches_it(live_dex): + fl = live_dex.freezelist + fl.clear() + # The USDCx wrapper's underlying stablecoin names its list through its imports. + assert live_dex._freezelist_for_token("x", "test_usdcx_stablecoin.aleo") == USDCX_LIST + assert live_dex._freezelist_for_token("x", "credits.aleo") is None + first = fl.resolve([ProofSlot(SHIELD_SWAP_FREEZELIST, ANY_ADDRESS), ProofSlot(USDCX_LIST, ANY_ADDRESS), ProofSlot(None, ANY_ADDRESS)]) + assert len(first) == 3 and first[2] == EMPTY_MERKLE_PROOF_PAIR + assert first[0] == fl.exclusion_proof(ANY_ADDRESS, SHIELD_SWAP_FREEZELIST) + assert first[1] == fl.exclusion_proof(ANY_ADDRESS, "test_usdcx_stablecoin.aleo") + for program in (SHIELD_SWAP_FREEZELIST, USDCX_LIST): + entry = fl._cache.current("testnet", program, fl.root(program)) + assert entry is not None and entry.tree[-1] == entry.root + + +@pytest.mark.slow +def test_usdcx_swap_and_claim_prove_against_both_lists(account_dex): + """Write tier: a USDCx → ETH swap spends a wrapped input (sender bundle + against ``test_usdcx_freezelist.aleo``) and its claim proves the signer + against Shield Swap's list — both bundles built by the SDK.""" + import time + + from aleo_shield_swap.errors import SwapOutputNotFinalizedError + from .conftest import poll_until, with_retry + + dex = account_dex + acct = dex._aleo.default_account + tokens = {t.address: t for t in dex.api.get_tokens()} + usdcx = next(t for t in tokens.values() if t.symbol == "USDCx") + pools = [p for p in dex.api.get_pools() if usdcx.address in (p.token0, p.token1) + and {tokens[p.token0].symbol, tokens[p.token1].symbol} == {"USDCx", "ETH"}] + assert pools, "no live USDCx/ETH pool" + pool = max(pools, key=lambda p: dex.get_slot(p.key).liquidity) + token_out = pool.token1 if pool.token0 == usdcx.address else pool.token0 + amount_in = 10 ** (usdcx.decimals - 2) # 0.01 USDCx + expected = dex._quote_expected_out(token_in_id=usdcx.address, token_out_id=token_out, + amount_in=amount_in, pool_key=pool.key) + call = dex.swap(pool_key=pool.key, token_in_id=usdcx.address, amount_in=amount_in, + expected_out=expected, slippage_bps=100, token_in_program=usdcx.underlying_program) + assert call._bound.function_name == "swap_from_wrapped" + handle = with_retry(lambda: call.delegate(acct)) + assert handle.swap_id + deadline = time.time() + 300 + while True: + try: + dex.get_swap_output(handle.swap_id) + break + except SwapOutputNotFinalizedError: + if time.time() > deadline: + pytest.fail("swap request did not finalize within 5 minutes") + time.sleep(10) + out = dex.get_swap_output(handle.swap_id) + claim = dex.claim_swap_output(handle) + assert claim._bound.function_name in ("claim_to_arc20_no_refund", "claim_to_arc20_refund_wrapped", "claim_swap_output", + "claim_swap_output_no_refund"), claim._bound.function_name + result = with_retry(lambda: claim.delegate(acct)) + assert result.amount_out > 0 and result.amount_out == out.amount_out + assert poll_until(lambda: _absent(dex, handle), 24, 5) + # Every bundle came from the resolver: both lists are now cached for this client. + for program in (SHIELD_SWAP_FREEZELIST, USDCX_LIST): + assert dex.freezelist._cache.current("testnet", program, dex.freezelist.root(program)) is not None + + +def _absent(dex, handle) -> bool: + from aleo_shield_swap.errors import SwapOutputNotFinalizedError + try: + dex.get_swap_output(handle.swap_id) + return False + except SwapOutputNotFinalizedError: + return True diff --git a/shield-swap-sdk/tests/test_async_client.py b/shield-swap-sdk/tests/test_async_client.py index 618fab58..7fccf601 100644 --- a/shield-swap-sdk/tests/test_async_client.py +++ b/shield-swap-sdk/tests/test_async_client.py @@ -101,6 +101,9 @@ def __init__(self, recorder, mappings, pid): self._mappings = mappings self.functions = _AsyncFunctions(recorder, pid) self.source = _valid_source(pid) + # As in the sync stub: an unknown token program is backed by an empty freeze list named after it. + default = [] if pid.endswith("freezelist.aleo") else [f"{pid.removesuffix('.aleo')}_freezelist.aleo"] + self.imports = list(recorder.imports.get(pid, default)) def mapping(self, name): return _AsyncMapping(self._mappings.get(name, {})) @@ -138,8 +141,16 @@ async def get_program_mapping_value(self, program_id, mapping_name, key): if program_id in self._recorder.missing_programs: from aleo import AleoNetworkError raise AleoNetworkError("404", status=404) + if mapping_name == "freeze_list_root": + from aleo.compliance import EMPTY_TREE_ROOT + root = self._recorder.roots.get(program_id, EMPTY_TREE_ROOT if program_id.endswith("freezelist.aleo") else None) + return None if root is None else f"{root}field" return self._recorder._mappings.get(mapping_name, {}).get(key) + async def get_program_freeze_list(self, program_id): + self._recorder.tree_reads.append(program_id) + return self._recorder.freeze_list_trees.get(program_id) + class _AsyncProvider: def __init__(self, records): @@ -161,7 +172,11 @@ def __init__(self, mappings=None, records=None): self.waited = [] self.registered_programs = [] self.mapping_reads = [] + self.tree_reads = [] self.missing_programs = set() + self.roots = {} + self.freeze_list_trees = {} + self.imports = {} self._mappings = mappings or {} self.programs = _AsyncPrograms(self, self._mappings) self.record_provider = _AsyncProvider( @@ -254,8 +269,8 @@ async def test_async_claim_wrapped_output_routes_through_router(): transaction_id="at1req", program="shield_swap.aleo") dex = AsyncShieldSwap(astub) - async def plain_program(token_id): # the registry, stubbed - return {"1field": "tok_in.aleo"}[token_id] + async def plain_program(token_id): # the registry, stubbed (a wrapped token's underlying program too) + return {"1field": "tok_in.aleo", "2field": "under_out.aleo"}[token_id] async def wrapper_program(token_id): return {"2field": "wrapped_out.aleo"}[token_id] diff --git a/shield-swap-sdk/tests/test_claim.py b/shield-swap-sdk/tests/test_claim.py index e8302071..269512bd 100644 --- a/shield-swap-sdk/tests/test_claim.py +++ b/shield-swap-sdk/tests/test_claim.py @@ -68,6 +68,14 @@ def test_claim_incomplete_handle_raises(): ShieldSwap(stub).claim_swap_output(_handle(blinding_factor=None)) +def _dex(stub): + """A client whose token registry names ``tok.aleo`` for every token; the + stub backs it with an empty freeze list (see ``StubAleo``).""" + dex = ShieldSwap(stub) + dex._token_program = lambda tid: "tok.aleo" + return dex + + def _stub_wrapped(swap_outputs, wrapped): """Stub with the given token ids marked wrapped on chain.""" return StubAleo(mappings={ @@ -80,7 +88,7 @@ def _stub_wrapped(swap_outputs, wrapped): def test_claim_wrapped_output_routes_through_router(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_TEXT}, wrapped={"2field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert stub.last_program == ROUTER_ID assert fn == "claim_to_wrapped_refund_arc20" @@ -91,7 +99,7 @@ def test_claim_wrapped_output_routes_through_router(): def test_claim_wrapped_refund_routes_through_router(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_TEXT}, wrapped={"1field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_arc20_refund_wrapped") assert len(args) == 9 @@ -99,7 +107,7 @@ def test_claim_wrapped_refund_routes_through_router(): def test_claim_both_wrapped_routes_with_two_proof_arrays(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_TEXT}, wrapped={"1field", "2field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_wrapped_refund_wrapped") assert len(args) == 10 @@ -108,7 +116,7 @@ def test_claim_both_wrapped_routes_with_two_proof_arrays(): def test_claim_plain_stays_on_core(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_TEXT}, wrapped=set()) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, _ = stub.last_call assert (stub.last_program, fn) == ("shield_swap.aleo", "claim_swap_output") @@ -117,7 +125,7 @@ def test_claim_plain_stays_on_core(): def test_claim_no_refund_plain_drops_remaining_input(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_NO_REFUND_TEXT}, wrapped=set()) - result = ShieldSwap(stub).claim_swap_output(_handle()).transact() + result = _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == ("shield_swap.aleo", "claim_swap_output_no_refund") assert args == ["11field", "aleo1blinded", "77field", @@ -128,7 +136,7 @@ def test_claim_no_refund_plain_drops_remaining_input(): def test_claim_no_refund_wrapped_output_takes_one_receiver_proof(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_NO_REFUND_TEXT}, wrapped={"2field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_wrapped_no_refund") assert len(args) == 8 @@ -140,7 +148,7 @@ def test_claim_no_refund_wrapped_input_only_needs_no_receiver_proof(): # The refund leg is wrapped but empty: the router variant still exists # (it must not pay through the core), yet takes no receiver proof. stub = _stub_wrapped({"77field": SWAP_OUTPUT_NO_REFUND_TEXT}, wrapped={"1field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_arc20_no_refund") assert len(args) == 7 @@ -149,7 +157,7 @@ def test_claim_no_refund_wrapped_input_only_needs_no_receiver_proof(): def test_claim_no_refund_both_wrapped_proves_only_the_output(): stub = _stub_wrapped({"77field": SWAP_OUTPUT_NO_REFUND_TEXT}, wrapped={"1field", "2field"}) - ShieldSwap(stub).claim_swap_output(_handle()).transact() + _dex(stub).claim_swap_output(_handle()).transact() fn, args = stub.last_call assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_wrapped_no_refund") assert len(args) == 8 diff --git a/shield-swap-sdk/tests/test_compliance.py b/shield-swap-sdk/tests/test_compliance.py new file mode 100644 index 00000000..9c93444e --- /dev/null +++ b/shield-swap-sdk/tests/test_compliance.py @@ -0,0 +1,283 @@ +"""Compliance proof slots per entrypoint (amm-v3 ``MerkleProofReference.md`` at f6694b7), +caller-supplied bundle validation, the legacy ``wrapper_proofs`` form, and automatic +resolution through the client against empty and populated freeze lists.""" +from __future__ import annotations + +import pytest + +from aleo import compliance as fl +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, FrozenAddressError, MerkleProof, MerkleProofPair +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap._core import default_merkle_proofs +from aleo_shield_swap._routing import LP_ROUTER_ID, REBALANCE_ROUTER_ID, ROUTER_ID +from aleo_shield_swap.compliance import ( + SHIELD_SWAP_FREEZELIST, Slot, claim_slots, collect_slots, increase_slots, merge_legacy_wrapper_proofs, + mint_slots, rebalance_slots, supplied_literals, swap_slots, +) + +from .conftest import POOL_TEXT, SIGNER, SLOT_TEXT, StubAleo +from .test_liquidity import POSITION_TEXT, TOKEN0_RECORD + +S, R, W = "aleo1signer", "aleo1recipient", "aleo1withdrawal" +T0, T1 = "1field", "2field" +LIT = EMPTY_MERKLE_PROOF_PAIR +ONE = MerkleProof((0,) * 16, 1) + + +def names(slots: list[Slot]) -> list[str]: + return [s.name for s in slots] + + +def subjects(slots: list[Slot]) -> list[tuple[str, str, str | None]]: + return [(s.kind, s.subject, s.token_id) for s in slots] + + +# ── Slot tables ─────────────────────────────────────────────────────────────── + +def test_core_functions_take_amm_bundles_only(): + assert subjects(mint_slots(False, False, S, R, W, T0, T1)) == [("amm", S, None), ("amm", R, None), ("amm", W, None)] + assert names(mint_slots(False, False, S, R, W, T0, T1)) == [ + "signer_merkle_proofs", "recipient_merkle_proofs", "withdrawal_merkle_proofs"] + assert subjects(collect_slots(False, False, S, W, T0, T1)) == [("amm", S, None), ("amm", W, None)] + assert names(collect_slots(False, False, S, W, T0, T1)) == ["owner_merkle_proofs", "withdrawal_merkle_proofs"] + assert subjects(claim_slots(False, False, False, S, T0, T1)) == [("amm", S, None)] + assert subjects(claim_slots(False, False, True, S, T0, T1)) == [("amm", S, None)] + assert swap_slots(False, S, T0) == [] and increase_slots(False, False, S, T0, T1) == [] + + +def test_trading_router_table(): + assert subjects(swap_slots(True, S, T0)) == [("wrapper", S, T0)] # swap_from_wrapped + assert names(claim_slots(False, False, True, S, T0, T1)) == ["merkle_proofs"] # claim_to_arc20_no_refund + assert subjects(claim_slots(True, False, True, S, T0, T1)) == [("amm", S, None), ("wrapper", S, T1)] # claim_to_wrapped_no_refund + assert names(claim_slots(True, False, True, S, T0, T1)) == ["merkle_proofs", "receiver_proof"] + assert subjects(claim_slots(True, False, False, S, T0, T1)) == [("amm", S, None), ("wrapper", S, T1)] # claim_to_wrapped_refund_arc20 + assert subjects(claim_slots(False, True, False, S, T0, T1)) == [("amm", S, None), ("wrapper", S, T0)] # claim_to_arc20_refund_wrapped + assert names(claim_slots(True, True, False, S, T0, T1)) == ["merkle_proofs", "out_receiver_proof", "refund_receiver_proof"] + assert subjects(claim_slots(True, True, False, S, T0, T1)) == [("amm", S, None), ("wrapper", S, T1), ("wrapper", S, T0)] + # A wrapped refund token on a no-refund claim keeps the router but takes no refund bundle. + assert subjects(claim_slots(True, True, True, S, T0, T1)) == [("amm", S, None), ("wrapper", S, T1)] + + +def test_lp_router_table(): + amm3 = [("amm", S, None), ("amm", R, None), ("amm", W, None)] + assert subjects(mint_slots(True, False, S, R, W, T0, T1)) == [("wrapper", S, T0), *amm3] # mint_from_wrapped_arc20 + assert subjects(mint_slots(False, True, S, R, W, T0, T1)) == [("wrapper", S, T1), *amm3] # mint_from_arc20_wrapped + assert subjects(mint_slots(True, True, S, R, W, T0, T1)) == [("wrapper", S, T0), ("wrapper", S, T1), *amm3] + assert names(mint_slots(True, True, S, R, W, T0, T1)) == [ + "sender_proof0", "sender_proof1", "amm_signer_merkle_proofs", "amm_recipient_merkle_proofs", "amm_withdrawal_merkle_proofs"] + assert subjects(increase_slots(True, False, S, T0, T1)) == [("wrapper", S, T0)] + assert subjects(increase_slots(False, True, S, T0, T1)) == [("wrapper", S, T1)] + assert subjects(increase_slots(True, True, S, T0, T1)) == [("wrapper", S, T0), ("wrapper", S, T1)] + owner2 = [("amm", S, None), ("amm", W, None)] + # Receiver bundles screen the NFT's withdrawal address, not the signer. + assert subjects(collect_slots(True, False, S, W, T0, T1)) == [*owner2, ("wrapper", W, T0)] + assert subjects(collect_slots(False, True, S, W, T0, T1)) == [*owner2, ("wrapper", W, T1)] + assert subjects(collect_slots(True, True, S, W, T0, T1)) == [*owner2, ("wrapper", W, T0), ("wrapper", W, T1)] + assert names(collect_slots(True, True, S, W, T0, T1)) == [ + "amm_owner_merkle_proofs", "amm_withdrawal_merkle_proofs", "receiver_proof0", "receiver_proof1"] + + +@pytest.mark.parametrize("w0,w1,f0,f1,expected", [ + (False, False, False, False, []), # plain_plain_none + (False, False, True, False, []), # plain_plain_one + (False, False, True, True, []), # plain_plain_both + (True, False, False, False, [("wrapper", W, T0)]), # wrapped_plain_none + (True, False, True, False, [("wrapper", S, T0), ("wrapper", W, T0)]), # wrapped_plain_fund0 + (True, False, False, True, [("wrapper", W, T0)]), # wrapped_plain_fund1 + (True, False, True, True, [("wrapper", S, T0), ("wrapper", W, T0)]), # wrapped_plain_both + (False, True, False, False, [("wrapper", W, T1)]), # plain_wrapped_none + (False, True, True, False, [("wrapper", W, T1)]), # plain_wrapped_fund0 + (False, True, False, True, [("wrapper", S, T1), ("wrapper", W, T1)]), # plain_wrapped_fund1 + (False, True, True, True, [("wrapper", S, T1), ("wrapper", W, T1)]), # plain_wrapped_both + (True, True, False, False, [("wrapper", W, T0), ("wrapper", W, T1)]), # wrapped_wrapped_none + (True, True, True, False, [("wrapper", S, T0), ("wrapper", W, T0), ("wrapper", W, T1)]), # wrapped_wrapped_one (side 0) + (True, True, False, True, [("wrapper", S, T1), ("wrapper", W, T0), ("wrapper", W, T1)]), # wrapped_wrapped_one (side 1) + (True, True, True, True, [("wrapper", S, T0), ("wrapper", S, T1), ("wrapper", W, T0), ("wrapper", W, T1)]), # wrapped_wrapped_both +]) +def test_rebalance_router_table(w0, w1, f0, f1, expected): + slots = rebalance_slots(w0, w1, f0, f1, S, W, T0, T1) + assert subjects(slots) == [*expected, ("amm", S, None), ("amm", W, None)] + assert names(slots)[-2:] == ["owner_proofs", "withdrawal_proofs"] + assert 2 <= len(slots) <= 6 + + +def test_rebalance_one_sided_wrapped_pair_names_the_single_sender_proof(): + assert names(rebalance_slots(True, True, True, False, S, W, T0, T1))[0] == "sender_proof" + assert names(rebalance_slots(True, True, False, True, S, W, T0, T1))[0] == "sender_proof" + assert names(rebalance_slots(True, True, True, True, S, W, T0, T1))[:2] == ["sender_proof0", "sender_proof1"] + assert names(rebalance_slots(True, False, True, False, S, W, T0, T1))[0] == "sender_proof0" + + +# ── Caller-supplied bundles ────────────────────────────────────────────────── + +def test_supplied_literals_accepts_every_bundle_form_in_order(): + slots = mint_slots(True, False, S, R, W, T0, T1) + pair = MerkleProofPair(ONE, ONE) + out = supplied_literals("mint", (LIT, pair, (ONE, ONE), LIT), slots) + assert out == [LIT] * 4 + + +def test_supplied_literals_count_mismatch_names_the_slots(): + with pytest.raises(ValueError, match=r"mint on this route takes 4 compliance proof bundle\(s\) \[sender_proof0 .*amm_withdrawal_merkle_proofs.*; received 3"): + supplied_literals("mint", (LIT, LIT, LIT), mint_slots(True, False, S, R, W, T0, T1)) + with pytest.raises(ValueError, match=r"takes 0 compliance proof bundle\(s\) \[none\]; received 1"): + supplied_literals("swap", (LIT,), []) + with pytest.raises(ValueError, match="encoded"): + supplied_literals("swap", ("nope",), swap_slots(True, S, T0)) + # A bare (MerkleProof, MerkleProof) tuple is one bundle, not two. + assert supplied_literals("swap", (ONE, ONE), swap_slots(True, S, T0)) == [LIT] + + +def test_merge_legacy_wrapper_proofs_fills_wrapper_slots_only(): + slots = collect_slots(True, True, S, W, T0, T1) + custom = LIT.replace("leaf_index: 1u32", "leaf_index: 3u32", 1) + assert merge_legacy_wrapper_proofs(slots, ["A", "B", "x", "y"], custom) == ["A", "B", custom, custom] + + +# ── Through the client ─────────────────────────────────────────────────────── + +def _stub(wrapped=(), **over): + mappings = { + "pools": {"5field": POOL_TEXT}, "slots": {"5field": SLOT_TEXT}, + "fee_tiers": {"3000u16": "true"}, "fee_to_tick_spacing": {"3000u16": "60u32"}, + "used_blinded_addresses": {}, "from_wrapper_token_id": {t: "9field" for t in wrapped}, + } + records = over.pop("records", [{"record_plaintext": TOKEN0_RECORD}, {"record_plaintext": POSITION_TEXT}]) + return StubAleo(mappings=mappings, records=records) + + +def _mint(dex, **kw): + dex.mint(pool_key="5field", tick_lower=-4080, tick_upper=4080, amount0_desired=10**9, amount1_desired=100, + token0_program="test_usdcx_stablecoin.aleo", token1_program="tok1.aleo", **kw) + + +def test_mint_resolves_every_slot_from_empty_lists_with_one_root_read_per_list(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + _mint(dex) + fn, args = stub.last_call + assert (stub.last_program, fn) == (LP_ROUTER_ID, "mint_from_wrapped_arc20") + assert args[2] == args[9] == args[10] == args[11] == default_merkle_proofs() + roots = [r for r in stub.mapping_reads if r[1] == "freeze_list_root"] + assert roots == [("test_usdcx_freezelist.aleo", "freeze_list_root", "1u8"), (SHIELD_SWAP_FREEZELIST, "freeze_list_root", "1u8")] + assert stub.tree_reads == [] # empty lists never fetch the tree + + +def test_mint_supplied_bundles_are_used_verbatim_and_never_read_the_chain(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + custom = [LIT.replace("leaf_index: 1u32", f"leaf_index: {i}u32", 1) for i in range(2, 6)] + _mint(dex, compliance_proofs=tuple(custom)) + _, args = stub.last_call + assert [args[2], args[9], args[10], args[11]] == custom + assert not [r for r in stub.mapping_reads if r[1] == "freeze_list_root"] + with pytest.raises(ValueError, match="takes 4 compliance proof bundle"): + _mint(dex, compliance_proofs=(LIT, LIT, LIT)) + with pytest.raises(ValueError, match="not both"): + _mint(dex, compliance_proofs=tuple(custom), wrapper_proofs=LIT) + + +def test_legacy_wrapper_proofs_fill_wrapper_slots_while_amm_slots_resolve(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + custom = LIT.replace("leaf_index: 1u32", "leaf_index: 7u32", 1) + _mint(dex, wrapper_proofs=custom) + _, args = stub.last_call + assert args[2] == custom and args[9] == args[10] == args[11] == default_merkle_proofs() + roots = [r[0] for r in stub.mapping_reads if r[1] == "freeze_list_root"] + assert roots == [SHIELD_SWAP_FREEZELIST] # the wrapper list is never read + + +def test_populated_lists_yield_real_witnesses_per_subject_and_cache_them(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + frozen = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t" + other = "aleo15mstsvdtzqf5nw8rfzx8mrllwxt907amfpt8nx3p8cskj4wd3uxq4uywn9" # sorts between the signer and the recipient + tree = fl.build_tree(fl.generate_leaves([frozen, other]), "testnet") + stub.roots[SHIELD_SWAP_FREEZELIST] = tree[-1] + stub.freeze_list_trees[SHIELD_SWAP_FREEZELIST] = tree + recipient = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" + _mint(dex, recipient=recipient, withdrawal=recipient) + _, args = stub.last_call + signer_proof, recipient_proof = args[9], args[10] + assert args[2] == default_merkle_proofs() # the USDCx list is still empty + assert signer_proof == fl.exclusion_proof_from_tree(tree, SIGNER, tree[-1], "testnet") + assert recipient_proof == args[11] == fl.exclusion_proof_from_tree(tree, recipient, tree[-1], "testnet") + assert signer_proof != recipient_proof + _mint(dex, recipient=recipient, withdrawal=recipient) + assert stub.tree_reads == [SHIELD_SWAP_FREEZELIST] # cached across calls while the root holds + with pytest.raises(FrozenAddressError): + _mint(dex, recipient=frozen) + # The list changes: a new root discards the cache and the (stale) published tree is rejected. + bigger = fl.build_tree(fl.generate_leaves([frozen, other, recipient]), "testnet") + stub.roots[SHIELD_SWAP_FREEZELIST] = bigger[-1] + with pytest.raises(fl.FreezeListError, match="does not match the on-chain root"): + _mint(dex) + stub.freeze_list_trees[SHIELD_SWAP_FREEZELIST] = bigger + with pytest.raises(FrozenAddressError): + _mint(dex, recipient=recipient) + assert stub.tree_reads == [SHIELD_SWAP_FREEZELIST] * 3 + + +def test_unreadable_root_is_fatal_and_names_the_fix(): + stub = _stub() + stub.roots[SHIELD_SWAP_FREEZELIST] = None + with pytest.raises(fl.FreezeListError, match=r"freeze_list_root\[1u8\] is unreadable; pass compliance_proofs="): + _mint(ShieldSwap(stub)) + + +def test_credits_wrapper_slot_gets_the_placeholder_without_a_list_read(): + stub = _stub(wrapped={"2field"}) + dex = ShieldSwap(stub) + dex.mint(pool_key="5field", tick_lower=-4080, tick_upper=4080, amount0_desired=10**9, amount1_desired=100, + token0_program="tok0.aleo", token1_program="credits.aleo") + _, args = stub.last_call + assert args[3] == default_merkle_proofs() + roots = [r[0] for r in stub.mapping_reads if r[1] == "freeze_list_root"] + assert roots == [SHIELD_SWAP_FREEZELIST] + + +def test_collect_receiver_slots_screen_the_nft_withdrawal_address(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + dex._token_program = lambda tid: "test_usdcx_stablecoin.aleo" + seen = [] + original = dex.freezelist.resolve + dex.freezelist.resolve = lambda slots: (seen.extend(slots), original(slots))[1] + dex.collect(pool_key="5field", amount0_requested=7, amount1_requested=8) + assert [(s.freezelist_program, s.address) for s in seen] == [ + (SHIELD_SWAP_FREEZELIST, SIGNER), (SHIELD_SWAP_FREEZELIST, "aleo1me"), ("test_usdcx_freezelist.aleo", "aleo1me")] + assert stub.last_call[0] == "collect_to_wrapped_arc20" and len(stub.last_call[1]) == 8 + + +def test_claim_router_slots_resolve_against_both_lists(): + from .test_claim import SWAP_OUTPUT_TEXT, _handle + stub = StubAleo(mappings={"pools": {"5field": POOL_TEXT}, "slots": {"5field": SLOT_TEXT}, + "swap_outputs": {"77field": SWAP_OUTPUT_TEXT}, + "from_wrapper_token_id": {"1field": "9field", "2field": "9field"}}) + dex = ShieldSwap(stub) + dex._token_program = lambda tid: {"1field": "test_usdcx_stablecoin.aleo", "2field": "test_usad_stablecoin.aleo"}[tid] + dex.claim_swap_output(_handle()).transact() + fn, args = stub.last_call + assert (stub.last_program, fn) == (ROUTER_ID, "claim_to_wrapped_refund_wrapped") + roots = [r[0] for r in stub.mapping_reads if r[1] == "freeze_list_root"] + assert roots == [SHIELD_SWAP_FREEZELIST, "test_usad_freezelist.aleo", "test_usdcx_freezelist.aleo"] + assert args[-3:] == [default_merkle_proofs()] * 3 + with pytest.raises(ValueError, match="claim_swap_output on this route takes 3"): + dex.claim_swap_output(_handle(), compliance_proofs=(LIT,)) + + +def test_swap_wrapped_input_resolves_the_sender_bundle_from_the_named_program(): + stub = _stub(wrapped={"1field"}) + dex = ShieldSwap(stub) + dex.swap(pool_key="5field", token_in_id="1field", amount_in=1_000_000, expected_out=1_000, + token_in_program="test_usdcx_stablecoin.aleo", identity=None, nonce=1, track=False) + fn, args = stub.last_call + assert (stub.last_program, fn) == (ROUTER_ID, "swap_from_wrapped") + assert args[1] == default_merkle_proofs() + roots = [r[0] for r in stub.mapping_reads if r[1] == "freeze_list_root"] + assert roots == ["test_usdcx_freezelist.aleo"] + with pytest.raises(ValueError, match="swap on this route takes 1"): + dex.swap(pool_key="5field", token_in_id="1field", amount_in=1_000_000, expected_out=1_000, + token_in_program="test_usdcx_stablecoin.aleo", nonce=1, track=False, compliance_proofs=()) + diff --git a/shield-swap-sdk/tests/test_gen_context.py b/shield-swap-sdk/tests/test_gen_context.py index c0a3adad..21e7ee37 100644 --- a/shield-swap-sdk/tests/test_gen_context.py +++ b/shield-swap-sdk/tests/test_gen_context.py @@ -61,5 +61,7 @@ def test_page_stays_compact(): # edit broke — this is deliberate room, not another squeeze. 26k → 32k # (2026-09) for the upstream sync: rebalancing (plan + execute, with the # price-fragility warning agents must relay), fill receipts, the routing - # graph, and the indexer's unclaimed/protocol-state views. - assert len(_render()) < 32_000 + # graph, and the indexer's unclaimed/protocol-state views. 32k → 34k + # (2026-10) for compliance proofs: six write methods gained a + # ``compliance_proofs`` parameter and a one-line note on what it takes. + assert len(_render()) < 34_000 diff --git a/shield-swap-sdk/tests/test_liquidity.py b/shield-swap-sdk/tests/test_liquidity.py index 8fde02ee..694c1553 100644 --- a/shield-swap-sdk/tests/test_liquidity.py +++ b/shield-swap-sdk/tests/test_liquidity.py @@ -246,15 +246,17 @@ def test_increase_wrapped_token1_routes_through_lp_router(): def test_collect_wrapped_sides_append_wrapper_proofs(): from aleo_shield_swap._routing import LP_ROUTER_ID stub = _wrapped_stub({"1field"}) - ShieldSwap(stub).collect(pool_key="5field", amount0_requested=7, - amount1_requested=8) + dex = ShieldSwap(stub) + dex._token_program = lambda tid: "tok.aleo" # registry row; the stub backs it with an empty list + dex.collect(pool_key="5field", amount0_requested=7, amount1_requested=8) fn, args = stub.last_call assert (stub.last_program, fn) == (LP_ROUTER_ID, "collect_to_wrapped_arc20") assert len(args) == 8 # + trailing wp0 stub = _wrapped_stub({"1field", "2field"}) - ShieldSwap(stub).collect(pool_key="5field", amount0_requested=7, - amount1_requested=8) + dex = ShieldSwap(stub) + dex._token_program = lambda tid: "tok.aleo" + dex.collect(pool_key="5field", amount0_requested=7, amount1_requested=8) fn, args = stub.last_call assert fn == "collect_to_wrapped_wrapped" assert len(args) == 9 # + wp0 + wp1 From ef9af24e739726c7aff1b5ac3a7bf40ec4f8ceb2 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Tue, 20 Oct 2026 13:14:43 -0400 Subject: [PATCH 02/12] test: live checks for frozen test accounts on the testnet freeze lists FROZEN_ADDRESS_1 / FROZEN_ADDRESS_2 name the frozen accounts. While a list does not contain them the checks skip and say so; once it does they prove the resolver refuses a proof for the address and that a swap, mint, unshield, or private burn involving it is refused before anything is proved or paid. --- bridge-sdk/tests/live/test_frozen_live.py | 97 +++++++++++ .../tests/integration/test_frozen_live.py | 155 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 bridge-sdk/tests/live/test_frozen_live.py create mode 100644 shield-swap-sdk/tests/integration/test_frozen_live.py diff --git a/bridge-sdk/tests/live/test_frozen_live.py b/bridge-sdk/tests/live/test_frozen_live.py new file mode 100644 index 00000000..48aefabb --- /dev/null +++ b/bridge-sdk/tests/live/test_frozen_live.py @@ -0,0 +1,97 @@ +"""Frozen addresses against the REAL testnet USDCx list (``test_usdcx_freezelist.aleo``). + +``FROZEN_ADDRESS_1`` / ``FROZEN_ADDRESS_2`` hold the private keys of two accounts that are (or +will be) frozen there. While the list does not contain them the matching checks skip and say so; +once it does they prove that the resolver refuses to build a proof for a frozen address and that a +USDCx unshield or private xReserve burn signed by a frozen account is refused before anything is +proved or paid, and that the SDK-built bundle authorizes locally. The program compares the +reconstructed root with its stored root in the *finalize*, on chain, so a bad bundle passed through +``compliance_proofs=`` is only rejected after the fee is paid — which is why the SDK verifies every +witness it builds against the root before a call is prepared. + +No test here submits a transaction. Gates: the frozen keys; ``ALEO_E2E_PRIVATE_KEY`` for the +simulate check (that account holds testnet USDCx records and shares its view key with the hosted +scanner already). +""" +from __future__ import annotations + +import os + +import pytest + +from aleo import Aleo, HTTPProvider, testnet +from aleo.compliance import address_to_field_int +from aleo_bridge import Bridge +from aleo_bridge.errors import ConfigurationError + +pytestmark = pytest.mark.live + +ENDPOINT = os.environ.get("ALEO_ENDPOINT", "https://edge.provable.com/api") +ASSET = "aleo-testnet/usdcx" +TOKEN = "test_usdcx_stablecoin.aleo" +LIST = "test_usdcx_freezelist.aleo" +SEPOLIA_RECIPIENT = "0x" + "11" * 20 + + +def _frozen_keys() -> list[tuple[str, str]]: + found = [(name, os.environ[name]) for name in ("FROZEN_ADDRESS_1", "FROZEN_ADDRESS_2") if os.environ.get(name)] + if not found: + pytest.skip("set FROZEN_ADDRESS_1 / FROZEN_ADDRESS_2 (private keys of the frozen test accounts)") + return found + + +def _bridge(private_key: str) -> Bridge: + aleo = Aleo(HTTPProvider(ENDPOINT, network="testnet")) + aleo.default_account = aleo.account.from_private_key(private_key) + return Bridge(aleo) + + +def _is_leaf(bridge: Bridge, address: str) -> bool: + tree = bridge.aleo.network.get_program_freeze_list(LIST) + assert tree is not None + return address_to_field_int(address) in tree[: (len(tree) + 1) // 2] + + +def test_frozen_addresses_are_listed_and_refused(): + listed_any = False + for name, key in _frozen_keys(): + bridge = _bridge(key) + address = bridge.aleo_address() + listed = _is_leaf(bridge, address) + listed_any |= listed + print(f" {name} {address[:12]}… on {LIST}: {'FROZEN' if listed else 'not listed'}") + if listed: + with pytest.raises(ConfigurationError, match="is on the freeze list"): + bridge.freezelist.exclusion_proof(address, TOKEN) + else: + assert bridge.freezelist.exclusion_proof(address, TOKEN).count("leaf_index") == 2 + if not listed_any: + pytest.skip(f"{LIST} does not contain the frozen test accounts yet") + + +def test_frozen_signer_unshield_and_private_burn_are_refused_before_proving(): + for _, key in _frozen_keys(): + bridge = _bridge(key) + address = bridge.aleo_address() + if not _is_leaf(bridge, address): + continue + # A record literal stands in for a scan: the refusal comes before the record is ever used. + record = f"{{ owner: {address}.private, amount: 5000000u128.private, _nonce: 0group.public }}" + with pytest.raises(ConfigurationError, match="is on the freeze list"): + bridge.unshield(ASSET, amount="1", record=record) + with pytest.raises(ConfigurationError, match="is on the freeze list"): + bridge.xreserve.burn(SEPOLIA_RECIPIENT, amount="3", record=record) + return + pytest.skip(f"{LIST} does not contain the frozen test accounts yet") + + +def test_sdk_built_bundle_authorizes_locally(): + key = os.environ.get("ALEO_E2E_PRIVATE_KEY") + if not key: + pytest.skip("ALEO_E2E_PRIVATE_KEY (testnet account holding USDCx records) is required") + bridge = _bridge(key) + bridge.aleo.records.register(bridge.aleo.default_account) + call = bridge.unshield(ASSET, amount="0.01") + assert call.function_name == "transfer_private_to_public" + assert call.inputs[-1] == bridge.freezelist.exclusion_proof(bridge.aleo_address(), TOKEN) + call.simulate() diff --git a/shield-swap-sdk/tests/integration/test_frozen_live.py b/shield-swap-sdk/tests/integration/test_frozen_live.py new file mode 100644 index 00000000..17fad141 --- /dev/null +++ b/shield-swap-sdk/tests/integration/test_frozen_live.py @@ -0,0 +1,155 @@ +"""Frozen addresses against the REAL testnet lists. + +``FROZEN_ADDRESS_1`` / ``FROZEN_ADDRESS_2`` hold the private keys of two +accounts that are (or will be) frozen on ``shield_swap_freezelist.aleo`` and +``test_usdcx_freezelist.aleo``. While a list does not yet contain them the +matching checks skip and say so; once it does they prove that + +- the published tree lists the address and the resolver refuses to build a + proof for it (``FrozenAddressError``, before anything is proved or paid), +- a swap signed by a frozen account and a mint naming a frozen recipient are + refused by the SDK the same way, +- and that the SDK-built bundles authorize locally. The program compares + the reconstructed root with its stored root in the *finalize*, on chain, + so a bad bundle passed through ``compliance_proofs=`` is only rejected + after the fee is paid — which is why the SDK verifies every witness it + builds against the root before a call is prepared. + +No test here submits a transaction. Run: ``-m live`` (the e2e account is +needed for the simulate check). +""" +from __future__ import annotations + +import os + +import pytest + +from aleo import testnet +from aleo.compliance import FrozenAddressError, address_to_field_int +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap.compliance import SHIELD_SWAP_FREEZELIST + +from .conftest import ENDPOINT, account_tier + +pytestmark = pytest.mark.live + +USDCX_LIST = "test_usdcx_freezelist.aleo" +USDCX_PROGRAM = "test_usdcx_stablecoin.aleo" +LISTS = (SHIELD_SWAP_FREEZELIST, USDCX_LIST) +KEY_VARS = ("FROZEN_ADDRESS_1", "FROZEN_ADDRESS_2") + + +def _frozen_keys() -> list[tuple[str, str]]: + keys = [(name, os.environ.get(name)) for name in KEY_VARS] + found = [(name, key) for name, key in keys if key] + if not found: + pytest.skip("set FROZEN_ADDRESS_1 / FROZEN_ADDRESS_2 (private keys of the frozen test accounts)") + return found # type: ignore[return-value] + + +def _address(private_key: str) -> str: + return str(testnet.PrivateKey.from_string(private_key).address) + + +def _is_leaf(dex: ShieldSwap, program: str, address: str) -> bool: + tree = dex._aleo.network.get_program_freeze_list(program) + assert tree is not None + leaves = (len(tree) + 1) // 2 + return address_to_field_int(address) in tree[:leaves] + + +def _frozen_on(dex: ShieldSwap, program: str) -> list[str]: + return [_address(key) for _, key in _frozen_keys() if _is_leaf(dex, program, _address(key))] + + +@pytest.fixture(scope="module") +def frozen_dex(live_dex_module): + """A client whose signer is the first frozen account (reads only; the account needs no funds).""" + from aleo import Aleo, HTTPProvider + _, key = _frozen_keys()[0] + aleo = Aleo(HTTPProvider(ENDPOINT, network="testnet")) + aleo.default_account = aleo.account.from_private_key(key) + dex = ShieldSwap(aleo) + dex.api.authenticate(str(aleo.default_account.address), + lambda msg: str(aleo.account.sign(msg.encode(), aleo.default_account))) + return dex + + +def test_frozen_addresses_are_listed_and_refused(live_dex): + seen = [] + for name, key in _frozen_keys(): + address = _address(key) + for program in LISTS: + listed = _is_leaf(live_dex, program, address) + seen.append((name, program, listed)) + print(f" {name} {address[:12]}… on {program}: {'FROZEN' if listed else 'not listed'}") + if listed: + with pytest.raises(FrozenAddressError, match="is on the freeze list"): + live_dex.freezelist.exclusion_proof(address, program) + else: + proof = live_dex.freezelist.exclusion_proof(address, program) + assert proof.count("leaf_index") == 2 + if not any(listed for _, _, listed in seen): + pytest.skip("neither list contains the frozen test accounts yet") + + +def test_frozen_signer_swap_is_refused_before_proving(frozen_dex, live_dex): + signer = str(frozen_dex._aleo.default_account.address) + if not _is_leaf(live_dex, USDCX_LIST, signer): + pytest.skip(f"{signer[:12]}… is not on {USDCX_LIST} yet") + tokens = {t.address: t for t in frozen_dex.api.get_tokens()} + usdcx = next(t for t in tokens.values() if t.symbol == "USDCx") + pool = next(p for p in frozen_dex.api.get_pools() if usdcx.address in (p.token0, p.token1)) + token_out = pool.token1 if pool.token0 == usdcx.address else pool.token0 + amount_in = 10 ** (usdcx.decimals - 2) + expected = frozen_dex._quote_expected_out(token_in_id=usdcx.address, token_out_id=token_out, + amount_in=amount_in, pool_key=pool.key) + # A record literal stands in for a scan: the refusal comes before the record is ever used. + record = f"{{ owner: {signer}.private, amount: {amount_in}u128.private, _nonce: 0group.public }}" + with pytest.raises(FrozenAddressError, match="is on the freeze list"): + frozen_dex.swap(pool_key=pool.key, token_in_id=usdcx.address, amount_in=amount_in, + expected_out=expected, slippage_bps=100, token_in_program=usdcx.underlying_program, + token_record=record, track=False) + + +@account_tier +def test_mint_naming_a_frozen_recipient_is_refused_before_proving(account_dex): + frozen = _frozen_on(account_dex, SHIELD_SWAP_FREEZELIST) + if not frozen: + pytest.skip(f"no frozen test account on {SHIELD_SWAP_FREEZELIST} yet") + held = {tid: v["private"] for tid, v in account_dex.get_balances().items()} + pool = next(p for p in account_dex.api.get_pools() if held.get(p.token0, 0) and held.get(p.token1, 0)) + slot = account_dex.get_slot(pool.key) + lo, hi = slot.tick_range(width=5) + with pytest.raises(FrozenAddressError, match="is on the freeze list"): + account_dex.mint(pool_key=pool.key, tick_lower=lo, tick_upper=hi, amount0_desired=1, amount1_desired=1, + recipient=frozen[0]) + with pytest.raises(FrozenAddressError, match="is on the freeze list"): + account_dex.mint(pool_key=pool.key, tick_lower=lo, tick_upper=hi, amount0_desired=1, amount1_desired=1, + withdrawal=frozen[0]) + + +@account_tier +def test_sdk_built_bundles_authorize_locally(account_dex): + """``simulate()`` runs the transitions off-chain: the bundles the SDK builds + for a wrapped-input swap pass that stage. (A bundle that does not match + the stored root passes it too — the root is checked in the finalize — so + this is a check of the SDK's own output, not a substitute for the chain.)""" + tokens = {t.address: t for t in account_dex.api.get_tokens()} + usdcx = next(t for t in tokens.values() if t.symbol == "USDCx") + pool = next(p for p in account_dex.api.get_pools() if usdcx.address in (p.token0, p.token1)) + token_out = pool.token1 if pool.token0 == usdcx.address else pool.token0 + amount_in = 10 ** (usdcx.decimals - 2) + expected = account_dex._quote_expected_out(token_in_id=usdcx.address, token_out_id=token_out, + amount_in=amount_in, pool_key=pool.key) + call = account_dex.swap(pool_key=pool.key, token_in_id=usdcx.address, amount_in=amount_in, + expected_out=expected, slippage_bps=100, token_in_program=usdcx.underlying_program, + track=False) + assert call._bound.function_name == "swap_from_wrapped" + call.simulate() + signer = str(account_dex._aleo.default_account.address) + explicit = account_dex.swap(pool_key=pool.key, token_in_id=usdcx.address, amount_in=amount_in, + expected_out=expected, slippage_bps=100, token_in_program=usdcx.underlying_program, + track=False, + compliance_proofs=(account_dex.freezelist.exclusion_proof(signer, USDCX_PROGRAM),)) + explicit.simulate() From a7160cca77077b0b370562f5f99eb3e9671a126d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 21 Oct 2026 19:17:02 -0400 Subject: [PATCH 03/12] refactor: align compliance helper names with veil 0.13 MerkleProof -> MerkleProofInput, ProofSlot -> ComplianceProofContext, exclusion_proof_from_tree -> generate_freezelist_proof, one_bundle -> literal_compliance_proof, bundle_literal -> format_merkle_proof_pair, KNOWN_FREEZE_LISTS -> KNOWN_TOKEN_FREEZELISTS, freeze_list_program -> freezelist_program, get_program_freeze_list -> get_compliance_freeze_list. None of these shipped in a release; compliance_proofs=, merkle_proof=, wrapper_proofs=, FreezeListProofs and the released bridge helpers are unchanged. --- bridge-sdk/README.md | 10 +- bridge-sdk/python/aleo_bridge/__init__.py | 4 +- bridge-sdk/python/aleo_bridge/freezelist.py | 16 +- bridge-sdk/python/aleo_bridge/privacy.py | 4 +- bridge-sdk/python/aleo_bridge/xreserve.py | 4 +- bridge-sdk/tests/conftest.py | 2 +- bridge-sdk/tests/live/test_compliance_live.py | 8 +- bridge-sdk/tests/live/test_frozen_live.py | 2 +- bridge-sdk/tests/test_freezelist.py | 2 +- sdk/python/aleo/__init__.py | 4 +- sdk/python/aleo/async_network_client.py | 2 +- sdk/python/aleo/compliance.py | 141 ++++++++++-------- sdk/python/aleo/facade/async_client.py | 4 +- sdk/python/aleo/facade/network.py | 4 +- sdk/python/aleo/network_client.py | 2 +- sdk/python/tests/test_compliance.py | 123 ++++++++------- sdk/python/tests/test_facade_async.py | 4 +- sdk/python/tests/test_facade_network.py | 4 +- sdk/python/tests/test_network_client.py | 10 +- sdk/python/tests/test_network_client_async.py | 8 +- shield-swap-sdk/README.md | 2 +- .../python/aleo_shield_swap/__init__.py | 2 +- .../python/aleo_shield_swap/async_client.py | 6 +- .../python/aleo_shield_swap/client.py | 6 +- .../python/aleo_shield_swap/compliance.py | 32 ++-- shield-swap-sdk/tests/conftest.py | 2 +- .../tests/integration/test_compliance_live.py | 10 +- .../tests/integration/test_frozen_live.py | 2 +- shield-swap-sdk/tests/test_async_client.py | 2 +- shield-swap-sdk/tests/test_compliance.py | 10 +- 30 files changed, 233 insertions(+), 199 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index fc98d4ca..877c989b 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -676,22 +676,22 @@ progress = bridge.execute(quote.plan) # private bur ``` Pass `compliance_proofs=` to use a bundle built elsewhere. The value is the -encoded literal, a `MerkleProofPair`, or a `(MerkleProof, MerkleProof)` tuple; +encoded literal, a `MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple; a one-element tuple of any of these is accepted too. `merkle_proof=` is the earlier name for the literal form and still works. ```python -from aleo_bridge import MerkleProof, MerkleProofPair +from aleo_bridge import MerkleProofInput, MerkleProofPair -bundle = MerkleProofPair(MerkleProof(siblings=(0,) * 16, leaf_index=1), - MerkleProof(siblings=(0,) * 16, leaf_index=1)) +bundle = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1), + MerkleProofInput(siblings=(0,) * 16, leaf_index=1)) receipt = bridge.unshield("aleo/usdcx", amount="2.5", compliance_proofs=bundle).delegate() ``` `bridge.freezelist.exclusion_proof(address, "usdcx_stablecoin.aleo")` returns the bundle for any address, and `bridge.freezelist.clear_cache()` forgets the cached trees. The published tree comes from -`aleo.network.get_program_freeze_list(program)`. +`aleo.network.get_compliance_freeze_list(program)`. ## Understand transfer costs diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index e0eca84f..711f1093 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -29,7 +29,7 @@ from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .eth import Ethereum, EthModule # noqa: E402 -from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProof, MerkleProofPair, ProofBundle # noqa: E402 +from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProofInput, MerkleProofPair, ProofBundle # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 from .lifecycle import prepare # noqa: E402 @@ -51,7 +51,7 @@ "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", - "MerkleProof", "MerkleProofPair", "ProofBundle", + "MerkleProofInput", "MerkleProofPair", "ProofBundle", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointLoadResult", "CheckpointProblem", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index c12e34f4..748ffd95 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -17,13 +17,13 @@ FREEZE_LIST_LAST_INDEX_MAPPING, FREEZE_LIST_MAPPING, FREEZE_LIST_ROOT_MAPPING, - KNOWN_FREEZE_LISTS, + KNOWN_TOKEN_FREEZELISTS, PROOF_SIBLINGS, ZERO_ADDRESS, FreezeListError, FreezeListProofs, FrozenAddressError, - MerkleProof, + MerkleProofInput, MerkleProofPair, ProofBundle, address_to_field_int, @@ -32,9 +32,9 @@ generate_leaves, hash_two, leaf_indices, - one_bundle, sibling_path, ) +from aleo.compliance import literal_compliance_proof as _literal_compliance_proof from .errors import ConfigurationError @@ -42,17 +42,17 @@ from .client import Bridge #: Token program -> its freeze-list program, for when ``Bridge.program(token).imports`` is unavailable. -FREEZE_LIST_PROGRAMS = {program: freezelist for program, freezelist in KNOWN_FREEZE_LISTS.items() if freezelist} +FREEZE_LIST_PROGRAMS = {program: freezelist for program, freezelist in KNOWN_TOKEN_FREEZELISTS.items() if freezelist} -def resolve_one_bundle(compliance_proofs: ProofBundle | tuple[ProofBundle] | None, merkle_proof: str | None) -> str | None: +def literal_compliance_proof(compliance_proofs: ProofBundle | tuple[ProofBundle] | None, merkle_proof: str | None) -> str | None: """The single ``[MerkleProof; 2]`` literal a bridge call was given, or ``None`` to compute one. Raises: ConfigurationError: Both arguments were given, or the value is not a proof bundle. """ try: - return one_bundle(compliance_proofs, merkle_proof) + return _literal_compliance_proof(compliance_proofs, merkle_proof) except FreezeListError as exc: raise ConfigurationError(str(exc)) from exc @@ -150,6 +150,6 @@ def exclusion_proof(self, address: str, program: str) -> str: __all__ = ["CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_PROGRAMS", "FREEZE_LIST_ROOT_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", - "FreezeListError", "FrozenAddressError", "MerkleProof", "MerkleProofPair", "ProofBundle", + "FreezeListError", "FrozenAddressError", "MerkleProofInput", "MerkleProofPair", "ProofBundle", "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", - "leaf_indices", "one_bundle", "sibling_path"] + "leaf_indices", "literal_compliance_proof", "sibling_path"] diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index ddc6e610..49b9ef33 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -9,7 +9,7 @@ from ._calls import AleoCall from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError -from .freezelist import resolve_one_bundle +from .freezelist import literal_compliance_proof from .registry import Asset from .types import PrivacyReceipt from .units import format_decimal_amount, resolve_amount @@ -123,7 +123,7 @@ def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None privacy = resolved.privacy assert privacy is not None record = record if record is not None else self.select_record(privacy.program, atomic) - proof = resolve_one_bundle(compliance_proofs, merkle_proof) + proof = literal_compliance_proof(compliance_proofs, merkle_proof) if privacy.kind == "arc22": if proof is None: proof = self._bridge.freezelist.exclusion_proof(self._bridge.aleo_address(), privacy.program) diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index 7659b3b5..8daa1097 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -12,7 +12,7 @@ from ._calls import AleoCall from ._keccak import keccak256 from .circle import CircleClient -from .freezelist import resolve_one_bundle +from .freezelist import literal_compliance_proof from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Route @@ -162,7 +162,7 @@ def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None """Build a burn with the lifecycle's validated execution-time estimate, if supplied.""" if mode not in BURN_MODES: raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") - merkle_proof = resolve_one_bundle(compliance_proofs, merkle_proof) + merkle_proof = literal_compliance_proof(compliance_proofs, merkle_proof) if mode != "private" and (record is not None or merkle_proof is not None): raise ConfigurationError( f"mode={mode!r} burns the public balance; record=/compliance_proofs= only apply to mode='private'") diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 25215f66..5ed5c078 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -163,7 +163,7 @@ def get_program_mapping_value(self, program_id: str, mapping_name: str, key: str self._aleo.mapping_reads.append((program_id, mapping_name, key)) return self._aleo.mappings.get(program_id, {}).get(mapping_name, {}).get(key) - def get_program_freeze_list(self, program_id: str) -> list[int] | None: + def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: """The compliance endpoint: script with ``FakeAleo.freeze_list_trees[program] = nodes`` (None = no list).""" self._aleo.tree_reads.append(program_id) tree = self._aleo.freeze_list_trees.get(program_id) diff --git a/bridge-sdk/tests/live/test_compliance_live.py b/bridge-sdk/tests/live/test_compliance_live.py index bea54239..2047a53f 100644 --- a/bridge-sdk/tests/live/test_compliance_live.py +++ b/bridge-sdk/tests/live/test_compliance_live.py @@ -13,7 +13,7 @@ import pytest -from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, exclusion_proof_from_tree +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, generate_freezelist_proof from . import config as live_config, helpers as live_helpers @@ -26,15 +26,15 @@ def test_published_tree_matches_the_on_chain_root(live_bridge): - tree = live_bridge.aleo.network.get_program_freeze_list(LIST) + tree = live_bridge.aleo.network.get_compliance_freeze_list(LIST) assert tree is not None and len(tree) >= 3 root = live_bridge.freezelist._proofs.root(LIST) assert tree[-1] == root - proof = exclusion_proof_from_tree(tree, live_bridge.aleo_address(), root, "mainnet") + proof = generate_freezelist_proof(tree, live_bridge.aleo_address(), root, "mainnet") assert proof.count("leaf_index") == 2 if root == EMPTY_TREE_ROOT: assert proof == EMPTY_MERKLE_PROOF_PAIR - assert live_bridge.aleo.network.get_program_freeze_list("credits.aleo") is None + assert live_bridge.aleo.network.get_compliance_freeze_list("credits.aleo") is None def test_exclusion_proof_resolves_through_the_token_program_and_caches(live_bridge): diff --git a/bridge-sdk/tests/live/test_frozen_live.py b/bridge-sdk/tests/live/test_frozen_live.py index 48aefabb..c0c32405 100644 --- a/bridge-sdk/tests/live/test_frozen_live.py +++ b/bridge-sdk/tests/live/test_frozen_live.py @@ -47,7 +47,7 @@ def _bridge(private_key: str) -> Bridge: def _is_leaf(bridge: Bridge, address: str) -> bool: - tree = bridge.aleo.network.get_program_freeze_list(LIST) + tree = bridge.aleo.network.get_compliance_freeze_list(LIST) assert tree is not None return address_to_field_int(address) in tree[: (len(tree) + 1) // 2] diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py index 3f6c1891..c5fc911f 100644 --- a/bridge-sdk/tests/test_freezelist.py +++ b/bridge-sdk/tests/test_freezelist.py @@ -209,7 +209,7 @@ def _seed_empty_list(bridge): def test_unshield_accepts_every_proof_form_and_computes_when_unset(bridge): _seed_empty_list(bridge) - pair = fl.MerkleProofPair(fl.MerkleProof((0,) * 16, 1), fl.MerkleProof((0,) * 16, 1)) + pair = fl.MerkleProofPair(fl.MerkleProofInput((0,) * 16, 1), fl.MerkleProofInput((0,) * 16, 1)) for kwargs in ({}, {"compliance_proofs": ONE_LITERAL}, {"compliance_proofs": (ONE_LITERAL,)}, {"compliance_proofs": pair}, {"compliance_proofs": (pair.left, pair.right)}, {"merkle_proof": ONE_LITERAL}): diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index f2c780d7..b07e00a0 100644 --- a/sdk/python/aleo/__init__.py +++ b/sdk/python/aleo/__init__.py @@ -35,10 +35,10 @@ FreezeListError as FreezeListError, FreezeListProofs as FreezeListProofs, FrozenAddressError as FrozenAddressError, - MerkleProof as MerkleProof, + MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, ProofBundle as ProofBundle, - ProofSlot as ProofSlot, + ComplianceProofContext as ComplianceProofContext, ) from .facade.errors import ( AleoError as AleoError, diff --git a/sdk/python/aleo/async_network_client.py b/sdk/python/aleo/async_network_client.py index 4c0b7b8b..b96eefdb 100644 --- a/sdk/python/aleo/async_network_client.py +++ b/sdk/python/aleo/async_network_client.py @@ -685,7 +685,7 @@ async def get_program_mapping_plaintext( import json as _json return Plaintext.from_string(_json.loads(raw)) - async def get_program_freeze_list(self, program_id: str) -> list[int] | None: + async def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: """Read the published compliance Merkle tree of a freeze-list program (awaited). Same contract as the sync client: node values leaves first and root diff --git a/sdk/python/aleo/compliance.py b/sdk/python/aleo/compliance.py index d9dc9909..cd49ae4f 100644 --- a/sdk/python/aleo/compliance.py +++ b/sdk/python/aleo/compliance.py @@ -4,14 +4,17 @@ a ``[MerkleProof; 2]`` argument proving that an address is *not* on the list. This module builds those arguments and caches them per client: -- :func:`exclusion_proof_from_tree` turns a published tree into the proof +- :func:`generate_freezelist_proof` turns a published tree into the proof literal for one address and checks every witness against the root. - :class:`FreezeListProofs` / :class:`AsyncFreezeListProofs` read the current root, fetch the tree only when the root changed, and reuse witnesses while the root is unchanged. While a list is empty the fixed empty-tree pair is returned without fetching anything beyond the root. -- :func:`freeze_list_program` finds the freeze-list program a token program +- :func:`freezelist_program` finds the freeze-list program a token program checks, so callers can name the token and let the SDK find the list. +- :func:`literal_compliance_proof` normalises a caller-supplied + ``compliance_proofs`` argument to its literal, and + :func:`format_merkle_proof_pair` encodes a structured pair. Tree layout (shared with the TypeScript SDK and the compliance API): leaves are frozen addresses as little-endian field integers, sorted ascending and @@ -44,7 +47,7 @@ #: Token programs whose freeze list is known without reading their imports. #: ``None`` marks a program that takes the proof argument and ignores it. -KNOWN_FREEZE_LISTS: dict[str, str | None] = { +KNOWN_TOKEN_FREEZELISTS: dict[str, str | None] = { "credits.aleo": None, "usdcx_stablecoin.aleo": "usdcx_freezelist.aleo", "test_usdcx_stablecoin.aleo": "test_usdcx_freezelist.aleo", @@ -137,14 +140,18 @@ def sibling_path(tree: Sequence[int], index: int, depth: int = DEFAULT_DEPTH) -> def format_merkle_proof(left: tuple[Sequence[int], int], right: tuple[Sequence[int], int]) -> str: - """A ``[MerkleProof; 2]`` literal from two (siblings, leaf_index) pairs.""" - return str(MerkleProofPair(MerkleProof(tuple(left[0]), left[1]), MerkleProof(tuple(right[0]), right[1]))) + """A ``[MerkleProof; 2]`` literal from two raw ``(siblings, leaf_index)`` pairs. + + The typed form is :func:`format_merkle_proof_pair`. + """ + return format_merkle_proof_pair((MerkleProofInput(tuple(left[0]), left[1]), + MerkleProofInput(tuple(right[0]), right[1]))) # ── Proof values ───────────────────────────────────────────────────────────── @dataclass(frozen=True) -class MerkleProof: +class MerkleProofInput: """One Merkle path of an exclusion proof, as the contracts take it. Attributes: @@ -166,7 +173,7 @@ def __str__(self) -> str: return "{ siblings: [" + ", ".join(f"{s}field" for s in self.siblings) + f"], leaf_index: {self.leaf_index}u32 }}" @classmethod - def from_string(cls, text: str) -> "MerkleProof": + def from_string(cls, text: str) -> "MerkleProofInput": """Parse one ``{ siblings: [..], leaf_index: Nu32 }`` literal.""" body = text.strip() if not (body.startswith("{") and body.endswith("}")): @@ -191,8 +198,8 @@ class MerkleProofPair: right: The path to the leaf just above the address. """ - left: MerkleProof - right: MerkleProof + left: MerkleProofInput + right: MerkleProofInput def __str__(self) -> str: return f"[{self.left}, {self.right}]" @@ -219,35 +226,43 @@ def from_string(cls, text: str) -> "MerkleProofPair": parts.append("".join(current)) if len(parts) != 2: raise FreezeListError(f"A [MerkleProof; 2] literal holds two proofs, found {len(parts)}") - return cls(MerkleProof.from_string(parts[0]), MerkleProof.from_string(parts[1])) + return cls(MerkleProofInput.from_string(parts[0]), MerkleProofInput.from_string(parts[1])) #: One compliance proof argument as a caller may supply it: the ``[MerkleProof; 2]`` -#: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProof` paths. -ProofBundle = str | MerkleProofPair | tuple[MerkleProof, MerkleProof] +#: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProofInput` paths. +ProofBundle = str | MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput] + + +def format_merkle_proof_pair(pair: MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput]) -> str: + """The ``[MerkleProof; 2]`` literal the transitions take, from a structured pair.""" + if isinstance(pair, MerkleProofPair): + return str(pair) + if isinstance(pair, tuple) and len(pair) == 2 and all(isinstance(p, MerkleProofInput) for p in pair): # pyright: ignore[reportUnnecessaryIsInstance] + return str(MerkleProofPair(pair[0], pair[1])) + raise FreezeListError("A proof pair is a MerkleProofPair or a (MerkleProofInput, MerkleProofInput) tuple") -def bundle_literal(bundle: ProofBundle) -> str: - """The ``[MerkleProof; 2]`` literal for a caller-supplied proof, validated.""" +def _bundle_literal(bundle: ProofBundle) -> str: + """The ``[MerkleProof; 2]`` literal for one caller-supplied bundle, validated.""" if isinstance(bundle, str): text = bundle.strip() if not (text.startswith("[") and text.endswith("]")): raise FreezeListError("A compliance proof must be an encoded [MerkleProof; 2] Aleo literal") return text - if isinstance(bundle, MerkleProofPair): - return str(bundle) - if isinstance(bundle, tuple) and len(bundle) == 2 and all(isinstance(p, MerkleProof) for p in bundle): # pyright: ignore[reportUnnecessaryIsInstance] - return str(MerkleProofPair(bundle[0], bundle[1])) + if isinstance(bundle, MerkleProofPair) or _is_path_tuple(bundle): + return format_merkle_proof_pair(bundle) raise FreezeListError("A compliance proof must be a [MerkleProof; 2] literal, a MerkleProofPair, " - "or a (MerkleProof, MerkleProof) tuple") + "or a (MerkleProofInput, MerkleProofInput) tuple") -def one_bundle(compliance_proofs: Any, legacy: Any = None, *, legacy_name: str = "merkle_proof") -> str | None: - """Normalise the proof arguments of a call that takes exactly one bundle. +def literal_compliance_proof(compliance_proofs: Any, legacy: Any = None, *, legacy_name: str = "merkle_proof") -> str | None: + """The one ``[MerkleProof; 2]`` literal a call was given, or ``None`` when the SDK should build it. - Accepts a :data:`ProofBundle` or a one-element tuple of one under + Normalises the proof arguments of a call that takes exactly one bundle: + a :data:`ProofBundle` or a one-element tuple of one under ``compliance_proofs``, and the literal string the call accepted before - under its old keyword. ``None`` from both means the SDK builds the proof. + under its old keyword. Reads nothing from the chain. Raises: FreezeListError: Both arguments were given, the tuple does not hold @@ -259,26 +274,30 @@ def one_bundle(compliance_proofs: Any, legacy: Any = None, *, legacy_name: str = if not isinstance(legacy, str): raise FreezeListError(f"{legacy_name}= takes the encoded [MerkleProof; 2] literal; " "pass structured proofs through compliance_proofs=") - return bundle_literal(legacy) + return _bundle_literal(legacy) if compliance_proofs is None: return None if isinstance(compliance_proofs, tuple): items = cast(tuple[Any, ...], compliance_proofs) if _is_path_tuple(items): - return bundle_literal(cast(ProofBundle, items)) + return _bundle_literal(cast(ProofBundle, items)) if len(items) != 1: - raise FreezeListError(f"This call takes exactly one compliance proof bundle; received {len(items)}") - return bundle_literal(cast(ProofBundle, items[0])) - return bundle_literal(cast(ProofBundle, compliance_proofs)) + raise FreezeListError(f"This operation requires exactly one compliance proof bundle; received {len(items)}") + return _bundle_literal(cast(ProofBundle, items[0])) + return _bundle_literal(cast(ProofBundle, compliance_proofs)) -def _is_path_tuple(value: tuple[Any, ...]) -> bool: - return len(value) == 2 and all(isinstance(p, MerkleProof) for p in value) +def _is_path_tuple(value: Any) -> bool: + """True for a ``(MerkleProofInput, MerkleProofInput)`` tuple, the structured form of one bundle.""" + if not isinstance(value, tuple): + return False + items = cast(tuple[Any, ...], value) + return len(items) == 2 and all(isinstance(p, MerkleProofInput) for p in items) # ── Witness construction and verification ──────────────────────────────────── -def exclusion_proof_from_tree(tree: Sequence[int], address: str, root: int, network: str) -> str: +def generate_freezelist_proof(tree: Sequence[int], address: str, root: int, network: str) -> str: """Build the ``[MerkleProof; 2]`` literal proving *address* is absent from *tree*. The published tree must be complete, sorted, and end in *root*; each @@ -312,14 +331,14 @@ def exclusion_proof_from_tree(tree: Sequence[int], address: str, root: int, netw if i and nodes[i] < nodes[i - 1]: raise FreezeListError("Invalid freeze-list tree: leaves must be sorted") left, right = leaf_indices(nodes, address) - paths = [MerkleProof(tuple(sibling_path(nodes, index, PROOF_SIBLINGS)), index) for index in (left, right)] + paths = [MerkleProofInput(tuple(sibling_path(nodes, index, PROOF_SIBLINGS)), index) for index in (left, right)] for path in paths: if _reconstruct_root(path, network) != root: raise FreezeListError("Freeze-list witness does not reconstruct the on-chain root") return str(MerkleProofPair(paths[0], paths[1])) -def _reconstruct_root(path: MerkleProof, network: str) -> int: +def _reconstruct_root(path: MerkleProofInput, network: str) -> int: value = path.siblings[0] for level in range(1, PROOF_SIBLINGS): sibling = path.siblings[level] @@ -334,15 +353,15 @@ def _reconstruct_root(path: MerkleProof, network: str) -> int: # ── Which list a program checks ────────────────────────────────────────────── -def freeze_list_from_imports(program: str, imports: Sequence[str] | None) -> str | None | _Unresolved: +def freezelist_from_imports(program: str, imports: Sequence[str] | None) -> str | None | _Unresolved: """The freeze-list program *program* checks, from its name, imports, or the known table.""" if program.endswith(FREEZELIST_SUFFIX): return program for dep in imports or (): if str(dep).endswith(FREEZELIST_SUFFIX): return str(dep) - if program in KNOWN_FREEZE_LISTS: - return KNOWN_FREEZE_LISTS[program] + if program in KNOWN_TOKEN_FREEZELISTS: + return KNOWN_TOKEN_FREEZELISTS[program] return _UNRESOLVED @@ -358,11 +377,11 @@ def _unresolved_error(program: str) -> FreezeListError: "pass compliance_proofs= explicitly") -def freeze_list_program(aleo: Any, program: str) -> str | None: +def freezelist_program(aleo: Any, program: str) -> str | None: """The freeze-list program that *program* checks, or ``None`` when it ignores the proof. A program named ``*freezelist.aleo`` is its own list. Otherwise the first - ``*freezelist.aleo`` import wins, then :data:`KNOWN_FREEZE_LISTS`. + ``*freezelist.aleo`` import wins, then :data:`KNOWN_TOKEN_FREEZELISTS`. Reads the program once through the facade when the table has no entry. Raises: @@ -370,32 +389,32 @@ def freeze_list_program(aleo: Any, program: str) -> str | None: """ if program.endswith(FREEZELIST_SUFFIX): return program - if program in KNOWN_FREEZE_LISTS: - return KNOWN_FREEZE_LISTS[program] + if program in KNOWN_TOKEN_FREEZELISTS: + return KNOWN_TOKEN_FREEZELISTS[program] from .facade.errors import AleoError as FacadeAleoError, ProgramNotFound try: imports = list(aleo.programs.get(program).imports) except (ProgramNotFound, FacadeAleoError): imports = [] - found = freeze_list_from_imports(program, imports) + found = freezelist_from_imports(program, imports) if isinstance(found, _Unresolved): raise _unresolved_error(program) return found -async def async_freeze_list_program(aleo: Any, program: str) -> str | None: - """Async twin of :func:`freeze_list_program`.""" +async def async_freezelist_program(aleo: Any, program: str) -> str | None: + """Async twin of :func:`freezelist_program`.""" if program.endswith(FREEZELIST_SUFFIX): return program - if program in KNOWN_FREEZE_LISTS: - return KNOWN_FREEZE_LISTS[program] + if program in KNOWN_TOKEN_FREEZELISTS: + return KNOWN_TOKEN_FREEZELISTS[program] from .facade.errors import AleoError as FacadeAleoError, ProgramNotFound try: handle = await aleo.programs.get(program) imports = list(handle.imports) except (ProgramNotFound, FacadeAleoError): imports = [] - found = freeze_list_from_imports(program, imports) + found = freezelist_from_imports(program, imports) if isinstance(found, _Unresolved): raise _unresolved_error(program) return found @@ -404,7 +423,7 @@ async def async_freeze_list_program(aleo: Any, program: str) -> str | None: # ── Resolution with a per-client cache ─────────────────────────────────────── @dataclass(frozen=True) -class ProofSlot: +class ComplianceProofContext: """One ``[MerkleProof; 2]`` argument a call needs. Attributes: @@ -476,7 +495,7 @@ def _witness(entry: _CachedList, network: str, address: str) -> str: return EMPTY_MERKLE_PROOF_PAIR # the same pair for every address; nothing to build or cache proof = entry.proofs.get(address) if proof is None: - proof = exclusion_proof_from_tree(entry.tree, address, entry.root, network) + proof = generate_freezelist_proof(entry.tree, address, entry.root, network) while len(entry.proofs) >= MAX_CACHED_ADDRESSES: entry.proofs.popitem(last=False) entry.proofs[address] = proof @@ -541,12 +560,12 @@ def exclusion_proof(self, address: str, program: str) -> str: FreezeListError: The root is unreadable, the published tree does not match it, or no list can be found for *program*. """ - freezelist = freeze_list_program(self._aleo, program) + freezelist = freezelist_program(self._aleo, program) if freezelist is None: return EMPTY_MERKLE_PROOF_PAIR - return self.resolve([ProofSlot(freezelist, address)])[0] + return self.resolve([ComplianceProofContext(freezelist, address)])[0] - def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: + def resolve(self, slots: Sequence[ComplianceProofContext | None]) -> list[str]: """One ``[MerkleProof; 2]`` literal per slot, in order. Every slot naming the same list is proved against one root read, so @@ -574,7 +593,7 @@ def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: def _entry(self, program: str, root: int) -> _CachedList: def fetch() -> list[int]: - return _fetched_tree(program, self._aleo.network.get_program_freeze_list(program)) + return _fetched_tree(program, self._aleo.network.get_compliance_freeze_list(program)) return self._cache.entry(self.network, program, root, fetch) @@ -611,12 +630,12 @@ async def tree(self, program: str, root: int | None = None) -> list[int]: async def exclusion_proof(self, address: str, program: str) -> str: """The ``[MerkleProof; 2]`` literal proving *address* is not on *program*'s list (awaited).""" - freezelist = await async_freeze_list_program(self._aleo, program) + freezelist = await async_freezelist_program(self._aleo, program) if freezelist is None: return EMPTY_MERKLE_PROOF_PAIR - return (await self.resolve([ProofSlot(freezelist, address)]))[0] + return (await self.resolve([ComplianceProofContext(freezelist, address)]))[0] - async def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: + async def resolve(self, slots: Sequence[ComplianceProofContext | None]) -> list[str]: """One ``[MerkleProof; 2]`` literal per slot, in order; one root read per list per call.""" roots: dict[str, int] = {} out: list[str] = [] @@ -640,7 +659,7 @@ async def resolve(self, slots: Sequence[ProofSlot | None]) -> list[str]: async def _entry(self, program: str, root: int) -> _CachedList: network = self.network if root != EMPTY_TREE_ROOT and self._cache.current(network, program, root) is None: - nodes = _fetched_tree(program, await self._aleo.network.get_program_freeze_list(program)) + nodes = _fetched_tree(program, await self._aleo.network.get_compliance_freeze_list(program)) return self._cache.entry(network, program, root, lambda: nodes) return self._cache.entry(network, program, root, _never) @@ -657,10 +676,10 @@ def _network_module(network: str) -> Any: __all__ = [ "CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_ROOT_MAPPING", - "KNOWN_FREEZE_LISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", + "KNOWN_TOKEN_FREEZELISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", "AsyncFreezeListProofs", "FreezeListError", "FreezeListProofs", "FrozenAddressError", - "MerkleProof", "MerkleProofPair", "ProofBundle", "ProofSlot", - "address_to_field_int", "async_freeze_list_program", "build_tree", "bundle_literal", - "exclusion_proof_from_tree", "format_merkle_proof", "freeze_list_from_imports", "freeze_list_program", - "generate_leaves", "hash_two", "leaf_indices", "one_bundle", "sibling_path", + "ComplianceProofContext", "MerkleProofInput", "MerkleProofPair", "ProofBundle", + "address_to_field_int", "async_freezelist_program", "build_tree", "format_merkle_proof", + "format_merkle_proof_pair", "freezelist_from_imports", "freezelist_program", "generate_freezelist_proof", + "generate_leaves", "hash_two", "leaf_indices", "literal_compliance_proof", "sibling_path", ] diff --git a/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index 59101f2a..d5a6c6df 100644 --- a/sdk/python/aleo/facade/async_client.py +++ b/sdk/python/aleo/facade/async_client.py @@ -575,14 +575,14 @@ async def get_program_mapping_value( program_id, mapping_name, key ) - async def get_program_freeze_list(self, program_id: str) -> list[int] | None: + async def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: """Return the published compliance Merkle tree of a freeze-list program (awaited). Node values leaves first and root last; ``None`` when the program has no freeze list. Verify the root against ``freeze_list_root`` before building a witness. """ - return await self._nc().get_program_freeze_list(program_id) + return await self._nc().get_compliance_freeze_list(program_id) async def get_public_balance(self, address: str) -> int: """Return the public credits balance for *address* in microcredits. diff --git a/sdk/python/aleo/facade/network.py b/sdk/python/aleo/facade/network.py index 70e51917..55ac9167 100644 --- a/sdk/python/aleo/facade/network.py +++ b/sdk/python/aleo/facade/network.py @@ -251,7 +251,7 @@ def get_program_mapping_value( """ return self._nc().get_program_mapping_value(program_id, mapping_name, key) - def get_program_freeze_list(self, program_id: str) -> list[int] | None: + def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: """Return the published compliance Merkle tree of a freeze-list program. Parameters @@ -266,7 +266,7 @@ def get_program_freeze_list(self, program_id: str) -> list[int] | None: has no freeze list. Verify the root against the program's ``freeze_list_root`` mapping before building a witness. """ - return self._nc().get_program_freeze_list(program_id) + return self._nc().get_compliance_freeze_list(program_id) def get_public_balance(self, address: str) -> int: """Return the public credits balance for *address* in microcredits. diff --git a/sdk/python/aleo/network_client.py b/sdk/python/aleo/network_client.py index aca5745d..919a89a3 100644 --- a/sdk/python/aleo/network_client.py +++ b/sdk/python/aleo/network_client.py @@ -750,7 +750,7 @@ def get_program_mapping_plaintext( ) return Plaintext.from_string(json.loads(raw)) - def get_program_freeze_list(self, program_id: str) -> list[int] | None: + def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: """Read the published compliance Merkle tree of a freeze-list program. The tree is what an exclusion proof is built from: node values from the diff --git a/sdk/python/tests/test_compliance.py b/sdk/python/tests/test_compliance.py index cccd2d94..f1a3c5b5 100644 --- a/sdk/python/tests/test_compliance.py +++ b/sdk/python/tests/test_compliance.py @@ -13,8 +13,8 @@ from aleo import compliance as fl from aleo.compliance import ( EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, AsyncFreezeListProofs, FreezeListError, FreezeListProofs, - FrozenAddressError, MerkleProof, MerkleProofPair, ProofSlot, bundle_literal, exclusion_proof_from_tree, - freeze_list_program, one_bundle, + FrozenAddressError, MerkleProofInput, MerkleProofPair, ComplianceProofContext, literal_compliance_proof, generate_freezelist_proof, + freezelist_program, literal_compliance_proof, ) A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" @@ -105,47 +105,56 @@ def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): # ── Proof values ────────────────────────────────────────────────────────────── def test_merkle_proof_round_trips_through_its_literal(): - one = MerkleProof(tuple([A_FIELD, 0] + [0] * 14), 1) + one = MerkleProofInput(tuple([A_FIELD, 0] + [0] * 14), 1) assert str(one) == "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" - assert MerkleProof.from_string(str(one)) == one + assert MerkleProofInput.from_string(str(one)) == one pair = MerkleProofPair(one, one) assert MerkleProofPair.from_string(str(pair)) == pair assert MerkleProofPair.from_string(EMPTY_MERKLE_PROOF_PAIR) == MerkleProofPair( - MerkleProof((0,) * 16, 1), MerkleProof((0,) * 16, 1)) + MerkleProofInput((0,) * 16, 1), MerkleProofInput((0,) * 16, 1)) with pytest.raises(FreezeListError, match="16 siblings"): - MerkleProof((1, 2), 0) + MerkleProofInput((1, 2), 0) with pytest.raises(FreezeListError, match="Not a MerkleProof"): - MerkleProof.from_string("[nope]") + MerkleProofInput.from_string("[nope]") with pytest.raises(FreezeListError, match="two proofs"): MerkleProofPair.from_string(f"[{EMPTY_ONE}]") -def test_bundle_literal_accepts_every_supported_form(): - one = MerkleProof((0,) * 16, 1) - assert bundle_literal(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR - assert bundle_literal(f" {EMPTY_MERKLE_PROOF_PAIR} ") == EMPTY_MERKLE_PROOF_PAIR - assert bundle_literal(MerkleProofPair(one, one)) == EMPTY_MERKLE_PROOF_PAIR - assert bundle_literal((one, one)) == EMPTY_MERKLE_PROOF_PAIR +def test_format_merkle_proof_pair_matches_the_raw_form(): + one = MerkleProofInput((0,) * 16, 1) + assert fl.format_merkle_proof_pair(MerkleProofPair(one, one)) == EMPTY_MERKLE_PROOF_PAIR + assert fl.format_merkle_proof_pair((one, one)) == EMPTY_MERKLE_PROOF_PAIR + assert fl.format_merkle_proof(([0] * 16, 1), ([0] * 16, 1)) == EMPTY_MERKLE_PROOF_PAIR + with pytest.raises(FreezeListError, match="proof pair"): + fl.format_merkle_proof_pair((one,)) # type: ignore[arg-type] + + +def test_literal_compliance_proof_accepts_every_bundle_form(): + one = MerkleProofInput((0,) * 16, 1) + assert literal_compliance_proof(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR + assert literal_compliance_proof(f" {EMPTY_MERKLE_PROOF_PAIR} ") == EMPTY_MERKLE_PROOF_PAIR + assert literal_compliance_proof(MerkleProofPair(one, one)) == EMPTY_MERKLE_PROOF_PAIR + assert literal_compliance_proof((one, one)) == EMPTY_MERKLE_PROOF_PAIR with pytest.raises(FreezeListError, match="encoded"): - bundle_literal("not a literal") + literal_compliance_proof("not a literal") with pytest.raises(FreezeListError, match="MerkleProofPair"): - bundle_literal((one,)) # type: ignore[arg-type] + literal_compliance_proof((one,)) # type: ignore[arg-type] -def test_one_bundle_handles_new_old_and_conflicting_arguments(): - one = MerkleProof((0,) * 16, 1) - assert one_bundle(None) is None - assert one_bundle(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR - assert one_bundle((EMPTY_MERKLE_PROOF_PAIR,)) == EMPTY_MERKLE_PROOF_PAIR - assert one_bundle((one, one)) == EMPTY_MERKLE_PROOF_PAIR # a bare path tuple is one bundle - assert one_bundle(((one, one),)) == EMPTY_MERKLE_PROOF_PAIR # a one-tuple of a path tuple too - assert one_bundle(None, EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR +def test_literal_compliance_proof_handles_new_old_and_conflicting_arguments(): + one = MerkleProofInput((0,) * 16, 1) + assert literal_compliance_proof(None) is None + assert literal_compliance_proof(EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR + assert literal_compliance_proof((EMPTY_MERKLE_PROOF_PAIR,)) == EMPTY_MERKLE_PROOF_PAIR + assert literal_compliance_proof((one, one)) == EMPTY_MERKLE_PROOF_PAIR # a bare path tuple is one bundle + assert literal_compliance_proof(((one, one),)) == EMPTY_MERKLE_PROOF_PAIR # a one-tuple of a path tuple too + assert literal_compliance_proof(None, EMPTY_MERKLE_PROOF_PAIR) == EMPTY_MERKLE_PROOF_PAIR with pytest.raises(FreezeListError, match="not both"): - one_bundle(EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR) + literal_compliance_proof(EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR) with pytest.raises(FreezeListError, match="exactly one"): - one_bundle((EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR)) + literal_compliance_proof((EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR)) with pytest.raises(FreezeListError, match="merkle_proof= takes the encoded"): - one_bundle(None, (one, one)) + literal_compliance_proof(None, (one, one)) # ── Witnesses against a populated tree ─────────────────────────────────────── @@ -158,7 +167,7 @@ def _populated() -> tuple[list[int], int]: def test_exclusion_proof_from_tree_builds_verified_witnesses(): tree, root = _populated() - literal = exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + literal = generate_freezelist_proof(tree, RECIPIENT, root, NET) pair = MerkleProofPair.from_string(literal) # RECIPIENT sorts above every leaf, so both paths open the last leaf (C). assert pair.left.leaf_index == 3 and pair.right.leaf_index == 3 @@ -166,37 +175,37 @@ def test_exclusion_proof_from_tree_builds_verified_witnesses(): assert pair.left.siblings[3:] == (0,) * 13 assert fl._reconstruct_root(pair.left, NET) == root # LOW sorts between the zero pad and B: the pair opens leaves 0 and 1. - low = MerkleProofPair.from_string(exclusion_proof_from_tree(tree, LOW, root, NET)) + low = MerkleProofPair.from_string(generate_freezelist_proof(tree, LOW, root, NET)) assert (low.left.leaf_index, low.right.leaf_index) == (0, 1) assert low.left.siblings[0] == 0 and low.right.siblings[0] == B_FIELD # MID sorts between B and A: leaves 1 and 2. - mid = MerkleProofPair.from_string(exclusion_proof_from_tree(tree, MID, root, NET)) + mid = MerkleProofPair.from_string(generate_freezelist_proof(tree, MID, root, NET)) assert (mid.left.leaf_index, mid.right.leaf_index) == (1, 2) # The zero address equals the padding leaf and is reported as frozen, like any leaf value. with pytest.raises(FrozenAddressError): - exclusion_proof_from_tree(tree, ZERO, root, NET) + generate_freezelist_proof(tree, ZERO, root, NET) def test_exclusion_proof_from_tree_empty_list_is_the_fixed_pair(): - assert exclusion_proof_from_tree([], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR - assert exclusion_proof_from_tree([0, 0, EMPTY_TREE_ROOT], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR + assert generate_freezelist_proof([], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR + assert generate_freezelist_proof([0, 0, EMPTY_TREE_ROOT], RECIPIENT, EMPTY_TREE_ROOT, NET) == EMPTY_MERKLE_PROOF_PAIR def test_exclusion_proof_from_tree_rejects_bad_input(): tree, root = _populated() with pytest.raises(FrozenAddressError, match="on the freeze list"): - exclusion_proof_from_tree(tree, A, root, NET) + generate_freezelist_proof(tree, A, root, NET) with pytest.raises(FreezeListError, match="does not match the on-chain root"): - exclusion_proof_from_tree(tree, RECIPIENT, root + 1, NET) + generate_freezelist_proof(tree, RECIPIENT, root + 1, NET) with pytest.raises(FreezeListError, match="complete binary tree"): - exclusion_proof_from_tree(tree[:-1], RECIPIENT, root, NET) + generate_freezelist_proof(tree[:-1], RECIPIENT, root, NET) unsorted = [0, A_FIELD, B_FIELD, C_FIELD, *tree[4:]] with pytest.raises(FreezeListError, match="sorted"): - exclusion_proof_from_tree(unsorted, RECIPIENT, root, NET) + generate_freezelist_proof(unsorted, RECIPIENT, root, NET) corrupt = list(tree) corrupt[4] += 1 # an inner node that does not hash from its leaves with pytest.raises(FreezeListError, match="does not reconstruct"): - exclusion_proof_from_tree(corrupt, RECIPIENT, root, NET) + generate_freezelist_proof(corrupt, RECIPIENT, root, NET) # ── Fake facade ─────────────────────────────────────────────────────────────── @@ -226,7 +235,7 @@ def get_program_mapping_value(self, program_id: str, mapping: str, key: str) -> self._fake.mapping_reads.append((program_id, mapping, key)) return self._fake.mappings.get(program_id, {}).get(mapping, {}).get(key) - def get_program_freeze_list(self, program_id: str) -> list[int] | None: + def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: self._fake.tree_reads.append(program_id) tree = self._fake.trees.get(program_id) if isinstance(tree, Exception): @@ -261,8 +270,8 @@ class _AsyncNetwork(_Network): async def get_program_mapping_value(self, program_id: str, mapping: str, key: str) -> Any: # type: ignore[override] return super().get_program_mapping_value(program_id, mapping, key) - async def get_program_freeze_list(self, program_id: str) -> list[int] | None: # type: ignore[override] - return super().get_program_freeze_list(program_id) + async def get_compliance_freeze_list(self, program_id: str) -> list[int] | None: # type: ignore[override] + return super().get_compliance_freeze_list(program_id) class FakeAsyncAleo(FakeAleo): @@ -276,14 +285,14 @@ def __init__(self) -> None: def test_freeze_list_program_resolution_order(): fake = FakeAleo() - assert freeze_list_program(fake, LIST) == LIST # already a list - assert freeze_list_program(fake, "usdcx_stablecoin.aleo") == LIST # known table, no fetch - assert freeze_list_program(fake, "credits.aleo") is None # ignores the proof + assert freezelist_program(fake, LIST) == LIST # already a list + assert freezelist_program(fake, "usdcx_stablecoin.aleo") == LIST # known table, no fetch + assert freezelist_program(fake, "credits.aleo") is None # ignores the proof assert fake.fetched == [] fake.imports["shield_swap_arc20_wrapped_usdcx.aleo"] = ["merkle_tree.aleo", "test_usdcx_freezelist.aleo"] - assert freeze_list_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" + assert freezelist_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" with pytest.raises(FreezeListError, match="names no freeze-list program"): - freeze_list_program(fake, "arc20_wbtc.aleo") + freezelist_program(fake, "arc20_wbtc.aleo") def test_freeze_list_program_propagates_unrelated_exceptions(): @@ -294,7 +303,7 @@ def boom(program_id: str) -> _Program: fake.programs.get = boom # type: ignore[method-assign] with pytest.raises(RuntimeError, match="rpc hiccup"): - freeze_list_program(fake, "arc20_wbtc.aleo") + freezelist_program(fake, "arc20_wbtc.aleo") # ── Resolution and caching ──────────────────────────────────────────────────── @@ -326,7 +335,7 @@ def test_populated_list_is_proved_from_the_published_tree_and_cached_per_root(): fake.trees[LIST] = tree proofs = FreezeListProofs(fake) first = proofs.exclusion_proof(RECIPIENT, LIST) - assert first == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert first == generate_freezelist_proof(tree, RECIPIENT, root, NET) assert proofs.exclusion_proof(RECIPIENT, LIST) == first assert proofs.exclusion_proof(LOW, LIST) != first # another subject, same tree assert fake.tree_reads == [LIST] # one fetch while the root is unchanged @@ -340,7 +349,7 @@ def test_populated_list_is_proved_from_the_published_tree_and_cached_per_root(): with pytest.raises(FrozenAddressError): proofs.exclusion_proof(RECIPIENT, LIST) assert fake.tree_reads == [LIST, LIST] - assert proofs.exclusion_proof(LOW, LIST) == exclusion_proof_from_tree(bigger, LOW, bigger[-1], NET) + assert proofs.exclusion_proof(LOW, LIST) == generate_freezelist_proof(bigger, LOW, bigger[-1], NET) proofs.clear() proofs.exclusion_proof(LOW, LIST) assert fake.tree_reads == [LIST, LIST, LIST] @@ -371,11 +380,11 @@ def test_resolve_reads_each_list_root_once_per_call_and_fills_placeholders(): fake.trees[LIST] = tree fake.set_root("shield_swap_freezelist.aleo", EMPTY_TREE_ROOT) proofs = FreezeListProofs(fake) - out = proofs.resolve([ProofSlot("shield_swap_freezelist.aleo", RECIPIENT), None, ProofSlot(None, RECIPIENT), - ProofSlot(LIST, RECIPIENT), ProofSlot(LIST, LOW), ProofSlot("shield_swap_freezelist.aleo", LOW)]) + out = proofs.resolve([ComplianceProofContext("shield_swap_freezelist.aleo", RECIPIENT), None, ComplianceProofContext(None, RECIPIENT), + ComplianceProofContext(LIST, RECIPIENT), ComplianceProofContext(LIST, LOW), ComplianceProofContext("shield_swap_freezelist.aleo", LOW)]) assert out[0] == out[1] == out[2] == out[5] == EMPTY_MERKLE_PROOF_PAIR - assert out[3] == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) - assert out[4] == exclusion_proof_from_tree(tree, LOW, root, NET) + assert out[3] == generate_freezelist_proof(tree, RECIPIENT, root, NET) + assert out[4] == generate_freezelist_proof(tree, LOW, root, NET) assert fake.mapping_reads == [("shield_swap_freezelist.aleo", "freeze_list_root", "1u8"), (LIST, "freeze_list_root", "1u8")] @@ -408,12 +417,12 @@ async def test_async_resolver_matches_sync(): fake.imports["shield_swap_arc20_wrapped_usdcx.aleo"] = ["test_usdcx_freezelist.aleo"] proofs = AsyncFreezeListProofs(fake) assert await proofs.exclusion_proof(RECIPIENT, "credits.aleo") == EMPTY_MERKLE_PROOF_PAIR - assert await proofs.exclusion_proof(RECIPIENT, "usdcx_stablecoin.aleo") == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) - assert await proofs.exclusion_proof(RECIPIENT, LIST) == exclusion_proof_from_tree(tree, RECIPIENT, root, NET) + assert await proofs.exclusion_proof(RECIPIENT, "usdcx_stablecoin.aleo") == generate_freezelist_proof(tree, RECIPIENT, root, NET) + assert await proofs.exclusion_proof(RECIPIENT, LIST) == generate_freezelist_proof(tree, RECIPIENT, root, NET) assert fake.tree_reads == [LIST] - out = await proofs.resolve([ProofSlot("shield_swap_freezelist.aleo", RECIPIENT), ProofSlot(LIST, LOW), None]) - assert out[0] == out[2] == EMPTY_MERKLE_PROOF_PAIR and out[1] == exclusion_proof_from_tree(tree, LOW, root, NET) - assert await fl.async_freeze_list_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" + out = await proofs.resolve([ComplianceProofContext("shield_swap_freezelist.aleo", RECIPIENT), ComplianceProofContext(LIST, LOW), None]) + assert out[0] == out[2] == EMPTY_MERKLE_PROOF_PAIR and out[1] == generate_freezelist_proof(tree, LOW, root, NET) + assert await fl.async_freezelist_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" with pytest.raises(FrozenAddressError): await proofs.exclusion_proof(A, LIST) fake.set_root(LIST, root + 1) diff --git a/sdk/python/tests/test_facade_async.py b/sdk/python/tests/test_facade_async.py index 100f5ae7..7f08fea5 100644 --- a/sdk/python/tests/test_facade_async.py +++ b/sdk/python/tests/test_facade_async.py @@ -588,5 +588,5 @@ async def test_async_network_get_program_freeze_list() -> None: f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list": jr(["0", "0", "7"]), f"{HOST}/programs/credits.aleo/compliance/freeze-list": jr({"statusCode": 404}, 404), }) - assert await a.network.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] - assert await a.network.get_program_freeze_list("credits.aleo") is None + assert await a.network.get_compliance_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] + assert await a.network.get_compliance_freeze_list("credits.aleo") is None diff --git a/sdk/python/tests/test_facade_network.py b/sdk/python/tests/test_facade_network.py index ea7b9116..a011f9d2 100644 --- a/sdk/python/tests/test_facade_network.py +++ b/sdk/python/tests/test_facade_network.py @@ -574,5 +574,5 @@ def test_get_program_freeze_list() -> None: resp_lib.add(resp_lib.GET, f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list", json=["0", "0", "7"]) resp_lib.add(resp_lib.GET, f"{HOST}/programs/credits.aleo/compliance/freeze-list", json={"statusCode": 404}, status=404) a = make_client() - assert a.network.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] - assert a.network.get_program_freeze_list("credits.aleo") is None + assert a.network.get_compliance_freeze_list("usdcx_freezelist.aleo") == [0, 0, 7] + assert a.network.get_compliance_freeze_list("credits.aleo") is None diff --git a/sdk/python/tests/test_network_client.py b/sdk/python/tests/test_network_client.py index bea19b1b..922576ef 100644 --- a/sdk/python/tests/test_network_client.py +++ b/sdk/python/tests/test_network_client.py @@ -949,7 +949,7 @@ def test_get_program_freeze_list_returns_tree_nodes_as_ints() -> None: resp_lib.add(resp_lib.GET, f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list", json=["0", "0", EMPTY_FREEZE_LIST_ROOT]) c = make_client() - assert c.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] + assert c.get_compliance_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] assert resp_lib.calls[0].request.url == f"{HOST}/programs/usdcx_freezelist.aleo/compliance/freeze-list" @@ -959,8 +959,8 @@ def test_get_program_freeze_list_is_none_when_the_program_has_no_list() -> None: json={"statusCode": 404, "message": "No current freeze list found for program: credits.aleo"}, status=404) resp_lib.add(resp_lib.GET, f"{HOST}/programs/nolist.aleo/compliance/freeze-list", body="null", content_type="application/json") c = make_client() - assert c.get_program_freeze_list("credits.aleo") is None - assert c.get_program_freeze_list("nolist.aleo") is None + assert c.get_compliance_freeze_list("credits.aleo") is None + assert c.get_compliance_freeze_list("nolist.aleo") is None @resp_lib.activate @@ -968,7 +968,7 @@ def test_get_program_freeze_list_rejects_non_decimal_nodes() -> None: resp_lib.add(resp_lib.GET, f"{HOST}/programs/bad.aleo/compliance/freeze-list", json=["0", "x"]) c = make_client() with pytest.raises(AleoNetworkError, match="decimal"): - c.get_program_freeze_list("bad.aleo") + c.get_compliance_freeze_list("bad.aleo") @resp_lib.activate @@ -976,4 +976,4 @@ def test_get_program_freeze_list_propagates_other_errors() -> None: resp_lib.add(resp_lib.GET, f"{HOST}/programs/down.aleo/compliance/freeze-list", json={"error": "boom"}, status=403) c = make_client() with pytest.raises(AleoNetworkError): - c.get_program_freeze_list("down.aleo") + c.get_compliance_freeze_list("down.aleo") diff --git a/sdk/python/tests/test_network_client_async.py b/sdk/python/tests/test_network_client_async.py index 875ab4e6..c4090811 100644 --- a/sdk/python/tests/test_network_client_async.py +++ b/sdk/python/tests/test_network_client_async.py @@ -613,8 +613,8 @@ async def test_async_get_program_freeze_list() -> None: f"{HOST}/programs/nolist.aleo/compliance/freeze-list": httpx.Response(200, content=b"null", headers={"content-type": "application/json"}), f"{HOST}/programs/bad.aleo/compliance/freeze-list": jr(["0", "x"]), }) - assert await c.get_program_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] - assert await c.get_program_freeze_list("credits.aleo") is None - assert await c.get_program_freeze_list("nolist.aleo") is None + assert await c.get_compliance_freeze_list("usdcx_freezelist.aleo") == [0, 0, int(EMPTY_FREEZE_LIST_ROOT)] + assert await c.get_compliance_freeze_list("credits.aleo") is None + assert await c.get_compliance_freeze_list("nolist.aleo") is None with pytest.raises(AleoNetworkError, match="decimal"): - await c.get_program_freeze_list("bad.aleo") + await c.get_compliance_freeze_list("bad.aleo") diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index ece8fa84..b56daa16 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -206,7 +206,7 @@ paid; an unreadable root or a published tree that does not match it raises Pass `compliance_proofs=` to supply the bundles yourself, as a tuple in the entrypoint's input order. Each element is the encoded literal, a -`MerkleProofPair`, or a `(MerkleProof, MerkleProof)` tuple. The count must +`MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple. The count must match the route the call resolves to, and the per-method aliases spell out the possibilities: diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index e824b8be..0e7c0466 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -26,7 +26,7 @@ ClaimProofs as ClaimProofs, CollectProofs as CollectProofs, IncreaseProofs as IncreaseProofs, - MerkleProof as MerkleProof, + MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, MintProofs as MintProofs, ProofBundle as ProofBundle, diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 7e4c218c..3067ecf2 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -16,7 +16,7 @@ from typing import Any, Callable, Generic, Optional, TypeVar from aleo import AleoNetworkError, ProgramNotFound -from aleo.compliance import AsyncFreezeListProofs, ProofSlot, async_freeze_list_program +from aleo.compliance import AsyncFreezeListProofs, ComplianceProofContext, async_freezelist_program from . import _generated as g from ._calls import extract_tx_id, root_outputs @@ -667,7 +667,7 @@ async def _freezelist_for_token(self, token_id: str, program: Optional[str] = No raise ValueError( f"Cannot resolve the program behind {token_id} to find its freeze " "list — pass compliance_proofs= explicitly.") from exc - return await async_freeze_list_program(self._aleo, program) + return await async_freezelist_program(self._aleo, program) async def _compliance_proofs(self, action: str, slots: list[Slot], compliance_proofs: Any, wrapper_proofs: Optional[str], @@ -678,7 +678,7 @@ async def _compliance_proofs(self, action: str, slots: list[Slot], compliance_pr if compliance_proofs is not None: return supplied_literals(action, compliance_proofs, slots) if wrapper_proofs is not None: - amm_only = [ProofSlot(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None + amm_only = [ComplianceProofContext(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None for slot in slots] return merge_legacy_wrapper_proofs(slots, await self.freezelist.resolve(amm_only), wrapper_proofs) known = programs or {} diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index f15de301..18ed276b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -17,7 +17,7 @@ from aleo import AleoNetworkError, ProgramNotFound from aleo.codegen.runtime import parse_plaintext -from aleo.compliance import FreezeListProofs, ProofSlot, freeze_list_program +from aleo.compliance import FreezeListProofs, ComplianceProofContext, freezelist_program from . import _generated as g from ._core import ( @@ -836,7 +836,7 @@ def _freezelist_for_token(self, token_id: str, program: Optional[str] = None) -> raise ValueError( f"Cannot resolve the program behind {token_id} to find its freeze " "list — pass compliance_proofs= explicitly.") from exc - return freeze_list_program(self._aleo, program) + return freezelist_program(self._aleo, program) def _compliance_proofs(self, action: str, slots: list[Slot], compliance_proofs: Any, wrapper_proofs: Optional[str], @@ -851,7 +851,7 @@ def _compliance_proofs(self, action: str, slots: list[Slot], compliance_proofs: if wrapper_proofs is not None: # Legacy form: the caller's one literal fills every wrapper slot; # only the AMM slots are resolved. - amm_only = [ProofSlot(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None + amm_only = [ComplianceProofContext(self.amm_freezelist_program, slot.subject) if slot.kind == "amm" else None for slot in slots] return merge_legacy_wrapper_proofs(slots, self.freezelist.resolve(amm_only), wrapper_proofs) known = programs or {} diff --git a/shield-swap-sdk/python/aleo_shield_swap/compliance.py b/shield-swap-sdk/python/aleo_shield_swap/compliance.py index 3be4c455..9d03472b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/compliance.py +++ b/shield-swap-sdk/python/aleo_shield_swap/compliance.py @@ -19,7 +19,7 @@ cached on the client until the list's root changes. - A tuple of bundles in the entrypoint's input order, each a literal string, a :class:`~aleo.compliance.MerkleProofPair`, or a pair of - :class:`~aleo.compliance.MerkleProof`. The count must match the route the + :class:`~aleo.compliance.MerkleProofInput`. The count must match the route the call resolves to; the type aliases below spell out the possible counts. The planners in this module are pure: they turn a route's shape into the @@ -34,11 +34,11 @@ from aleo.compliance import ( EMPTY_MERKLE_PROOF_PAIR, FreezeListError, - MerkleProof, + MerkleProofInput, MerkleProofPair, ProofBundle, - ProofSlot, - bundle_literal, + ComplianceProofContext, + literal_compliance_proof, ) SHIELD_SWAP_FREEZELIST = "shield_swap_freezelist.aleo" @@ -191,30 +191,36 @@ def supplied_literals(action: str, supplied: Sequence[ProofBundle], slots: Seque raise ValueError(f"{action} on this route takes {len(slots)} compliance proof bundle(s) " f"[{expected}]; received {len(supplied)}") try: - return [bundle_literal(b) for b in supplied] + return [_literal(b) for b in supplied] except FreezeListError as exc: raise ValueError(f"{action}: {exc}") from exc +def _literal(bundle: ProofBundle) -> str: + literal = literal_compliance_proof(bundle) + assert literal is not None # a bundle is never None + return literal + + def _looks_like_one_bundle(value: Sequence[ProofBundle]) -> bool: - return len(value) == 2 and all(isinstance(p, MerkleProof) for p in value) + return len(value) == 2 and all(isinstance(p, MerkleProofInput) for p in value) -def planned_proof_slots(slots: Sequence[Slot], amm_list: Optional[str], list_for_token: ListResolver) -> list[ProofSlot]: - """The :class:`aleo.compliance.ProofSlot` per planned slot: which list and which address.""" - out: list[ProofSlot] = [] +def planned_proof_slots(slots: Sequence[Slot], amm_list: Optional[str], list_for_token: ListResolver) -> list[ComplianceProofContext]: + """The :class:`aleo.compliance.ComplianceProofContext` per planned slot: which list and which address.""" + out: list[ComplianceProofContext] = [] for slot in slots: if slot.kind == "amm": - out.append(ProofSlot(amm_list, slot.subject)) + out.append(ComplianceProofContext(amm_list, slot.subject)) else: assert slot.token_id is not None - out.append(ProofSlot(list_for_token(slot.token_id), slot.subject)) + out.append(ComplianceProofContext(list_for_token(slot.token_id), slot.subject)) return out def merge_legacy_wrapper_proofs(slots: Sequence[Slot], resolved: Sequence[str], wrapper_proofs: str) -> list[str]: """Put the caller's one legacy ``wrapper_proofs`` literal into every wrapper slot, keeping resolved AMM bundles.""" - literal = bundle_literal(wrapper_proofs) + literal = _literal(wrapper_proofs) return [literal if slot.kind == "wrapper" else proof for slot, proof in zip(slots, resolved)] @@ -227,7 +233,7 @@ def check_proof_arguments(compliance_proofs: object, wrapper_proofs: Optional[st __all__ = [ "EMPTY_MERKLE_PROOF_PAIR", "SHIELD_SWAP_FREEZELIST", "ClaimProofs", "CollectProofs", "IncreaseProofs", "MintProofs", "RebalanceProofs", "SwapProofs", - "MerkleProof", "MerkleProofPair", "ProofBundle", "Slot", + "MerkleProofInput", "MerkleProofPair", "ProofBundle", "Slot", "check_proof_arguments", "claim_slots", "collect_slots", "increase_slots", "merge_legacy_wrapper_proofs", "mint_slots", "planned_proof_slots", "rebalance_slots", "supplied_literals", "swap_slots", ] diff --git a/shield-swap-sdk/tests/conftest.py b/shield-swap-sdk/tests/conftest.py index fb517b8b..909a8b83 100644 --- a/shield-swap-sdk/tests/conftest.py +++ b/shield-swap-sdk/tests/conftest.py @@ -206,7 +206,7 @@ def get_program_mapping_value(self, program_id, mapping_name, key): return None if root is None else f"{root}field" return self._recorder._mappings.get(mapping_name, {}).get(key) - def get_program_freeze_list(self, program_id): + def get_compliance_freeze_list(self, program_id): """The compliance endpoint: ``StubAleo.freeze_list_trees[program]`` (None = no list).""" self._recorder.tree_reads.append(program_id) return self._recorder.freeze_list_trees.get(program_id) diff --git a/shield-swap-sdk/tests/integration/test_compliance_live.py b/shield-swap-sdk/tests/integration/test_compliance_live.py index 4a0d07fa..76693c62 100644 --- a/shield-swap-sdk/tests/integration/test_compliance_live.py +++ b/shield-swap-sdk/tests/integration/test_compliance_live.py @@ -8,7 +8,7 @@ import pytest -from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, ProofSlot, exclusion_proof_from_tree +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, ComplianceProofContext, generate_freezelist_proof from aleo_shield_swap.compliance import SHIELD_SWAP_FREEZELIST pytestmark = pytest.mark.live @@ -20,19 +20,19 @@ @pytest.mark.parametrize("program", [SHIELD_SWAP_FREEZELIST, USDCX_LIST]) def test_published_tree_matches_the_on_chain_root(live_dex, program): aleo = live_dex._aleo - tree = aleo.network.get_program_freeze_list(program) + tree = aleo.network.get_compliance_freeze_list(program) assert tree is not None and len(tree) >= 3 and (len(tree) + 1) % 2 == 0 root = live_dex.freezelist.root(program) assert tree[-1] == root # A witness built from the published tree reconstructs that root. - proof = exclusion_proof_from_tree(tree, ANY_ADDRESS, root, "testnet") + proof = generate_freezelist_proof(tree, ANY_ADDRESS, root, "testnet") assert proof.startswith("[{ siblings: [") and proof.count("leaf_index") == 2 if root == EMPTY_TREE_ROOT: assert proof == EMPTY_MERKLE_PROOF_PAIR def test_programs_without_a_list_answer_none(live_dex): - assert live_dex._aleo.network.get_program_freeze_list("credits.aleo") is None + assert live_dex._aleo.network.get_compliance_freeze_list("credits.aleo") is None def test_resolver_finds_the_list_behind_each_token_and_caches_it(live_dex): @@ -41,7 +41,7 @@ def test_resolver_finds_the_list_behind_each_token_and_caches_it(live_dex): # The USDCx wrapper's underlying stablecoin names its list through its imports. assert live_dex._freezelist_for_token("x", "test_usdcx_stablecoin.aleo") == USDCX_LIST assert live_dex._freezelist_for_token("x", "credits.aleo") is None - first = fl.resolve([ProofSlot(SHIELD_SWAP_FREEZELIST, ANY_ADDRESS), ProofSlot(USDCX_LIST, ANY_ADDRESS), ProofSlot(None, ANY_ADDRESS)]) + first = fl.resolve([ComplianceProofContext(SHIELD_SWAP_FREEZELIST, ANY_ADDRESS), ComplianceProofContext(USDCX_LIST, ANY_ADDRESS), ComplianceProofContext(None, ANY_ADDRESS)]) assert len(first) == 3 and first[2] == EMPTY_MERKLE_PROOF_PAIR assert first[0] == fl.exclusion_proof(ANY_ADDRESS, SHIELD_SWAP_FREEZELIST) assert first[1] == fl.exclusion_proof(ANY_ADDRESS, "test_usdcx_stablecoin.aleo") diff --git a/shield-swap-sdk/tests/integration/test_frozen_live.py b/shield-swap-sdk/tests/integration/test_frozen_live.py index 17fad141..28b624ba 100644 --- a/shield-swap-sdk/tests/integration/test_frozen_live.py +++ b/shield-swap-sdk/tests/integration/test_frozen_live.py @@ -52,7 +52,7 @@ def _address(private_key: str) -> str: def _is_leaf(dex: ShieldSwap, program: str, address: str) -> bool: - tree = dex._aleo.network.get_program_freeze_list(program) + tree = dex._aleo.network.get_compliance_freeze_list(program) assert tree is not None leaves = (len(tree) + 1) // 2 return address_to_field_int(address) in tree[:leaves] diff --git a/shield-swap-sdk/tests/test_async_client.py b/shield-swap-sdk/tests/test_async_client.py index 7fccf601..3fcaf971 100644 --- a/shield-swap-sdk/tests/test_async_client.py +++ b/shield-swap-sdk/tests/test_async_client.py @@ -147,7 +147,7 @@ async def get_program_mapping_value(self, program_id, mapping_name, key): return None if root is None else f"{root}field" return self._recorder._mappings.get(mapping_name, {}).get(key) - async def get_program_freeze_list(self, program_id): + async def get_compliance_freeze_list(self, program_id): self._recorder.tree_reads.append(program_id) return self._recorder.freeze_list_trees.get(program_id) diff --git a/shield-swap-sdk/tests/test_compliance.py b/shield-swap-sdk/tests/test_compliance.py index 9c93444e..8cf14ea3 100644 --- a/shield-swap-sdk/tests/test_compliance.py +++ b/shield-swap-sdk/tests/test_compliance.py @@ -6,7 +6,7 @@ import pytest from aleo import compliance as fl -from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, FrozenAddressError, MerkleProof, MerkleProofPair +from aleo.compliance import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT, FrozenAddressError, MerkleProofInput, MerkleProofPair from aleo_shield_swap import ShieldSwap from aleo_shield_swap._core import default_merkle_proofs from aleo_shield_swap._routing import LP_ROUTER_ID, REBALANCE_ROUTER_ID, ROUTER_ID @@ -21,7 +21,7 @@ S, R, W = "aleo1signer", "aleo1recipient", "aleo1withdrawal" T0, T1 = "1field", "2field" LIT = EMPTY_MERKLE_PROOF_PAIR -ONE = MerkleProof((0,) * 16, 1) +ONE = MerkleProofInput((0,) * 16, 1) def names(slots: list[Slot]) -> list[str]: @@ -124,7 +124,7 @@ def test_supplied_literals_count_mismatch_names_the_slots(): supplied_literals("swap", (LIT,), []) with pytest.raises(ValueError, match="encoded"): supplied_literals("swap", ("nope",), swap_slots(True, S, T0)) - # A bare (MerkleProof, MerkleProof) tuple is one bundle, not two. + # A bare (MerkleProofInput, MerkleProofInput) tuple is one bundle, not two. assert supplied_literals("swap", (ONE, ONE), swap_slots(True, S, T0)) == [LIT] @@ -201,8 +201,8 @@ def test_populated_lists_yield_real_witnesses_per_subject_and_cache_them(): _, args = stub.last_call signer_proof, recipient_proof = args[9], args[10] assert args[2] == default_merkle_proofs() # the USDCx list is still empty - assert signer_proof == fl.exclusion_proof_from_tree(tree, SIGNER, tree[-1], "testnet") - assert recipient_proof == args[11] == fl.exclusion_proof_from_tree(tree, recipient, tree[-1], "testnet") + assert signer_proof == fl.generate_freezelist_proof(tree, SIGNER, tree[-1], "testnet") + assert recipient_proof == args[11] == fl.generate_freezelist_proof(tree, recipient, tree[-1], "testnet") assert signer_proof != recipient_proof _mint(dex, recipient=recipient, withdrawal=recipient) assert stub.tree_reads == [SHIELD_SWAP_FREEZELIST] # cached across calls while the root holds From 542f71979bf4d0102eaaf71b843fe05c6e2f55fb Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 03:41:11 -0400 Subject: [PATCH 04/12] feat(compliance): accept field and u32 literals when building MerkleProofInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit siblings take ints or "…field" strings and leaf_index an int or "Nu32"; values normalise to ints so equality and hashing are unchanged, and sibling_literals renders the literal form back out. --- bridge-sdk/README.md | 2 +- sdk/python/aleo/compliance.py | 49 ++++++++++++++++++++++++----- sdk/python/tests/test_compliance.py | 15 +++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 877c989b..d96c2765 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -684,7 +684,7 @@ earlier name for the literal form and still works. from aleo_bridge import MerkleProofInput, MerkleProofPair bundle = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1), - MerkleProofInput(siblings=(0,) * 16, leaf_index=1)) + MerkleProofInput(siblings=("0field",) * 16, leaf_index="1u32")) # ints or literals receipt = bridge.unshield("aleo/usdcx", amount="2.5", compliance_proofs=bundle).delegate() ``` diff --git a/sdk/python/aleo/compliance.py b/sdk/python/aleo/compliance.py index cd49ae4f..23d2a0c9 100644 --- a/sdk/python/aleo/compliance.py +++ b/sdk/python/aleo/compliance.py @@ -139,7 +139,8 @@ def sibling_path(tree: Sequence[int], index: int, depth: int = DEFAULT_DEPTH) -> return path -def format_merkle_proof(left: tuple[Sequence[int], int], right: tuple[Sequence[int], int]) -> str: +def format_merkle_proof(left: tuple[Sequence[int | str], int | str], + right: tuple[Sequence[int | str], int | str]) -> str: """A ``[MerkleProof; 2]`` literal from two raw ``(siblings, leaf_index)`` pairs. The typed form is :func:`format_merkle_proof_pair`. @@ -150,10 +151,35 @@ def format_merkle_proof(left: tuple[Sequence[int], int], right: tuple[Sequence[i # ── Proof values ───────────────────────────────────────────────────────────── -@dataclass(frozen=True) +def _field_value(value: int | str) -> int: + """An integer field element from an ``int`` or a ``"…field"`` literal.""" + if isinstance(value, int): + return value + text = value.strip() + if not text.endswith("field") or not text[:-5].isdigit(): + raise FreezeListError(f"Not a field literal: {value!r}") + return int(text[:-5]) + + +def _u32_value(value: int | str) -> int: + """An integer from an ``int`` or a ``"Nu32"`` literal.""" + if isinstance(value, int): + return value + text = value.strip() + if not text.endswith("u32") or not text[:-3].isdigit(): + raise FreezeListError(f"Not a u32 literal: {value!r}") + return int(text[:-3]) + + +@dataclass(frozen=True, init=False) class MerkleProofInput: """One Merkle path of an exclusion proof, as the contracts take it. + ``siblings`` may be given as integers or as ``"…field"`` literals (the + TypeScript SDK's form) and ``leaf_index`` as an ``int`` or ``"Nu32"``; + both are stored normalised, so equal paths compare equal however they + were built. :attr:`sibling_literals` gives the siblings back as literals. + Attributes: siblings: The leaf followed by one sibling per level, 16 field values in all; unused levels are zero. @@ -163,14 +189,23 @@ class MerkleProofInput: siblings: tuple[int, ...] leaf_index: int - def __post_init__(self) -> None: - if len(self.siblings) != PROOF_SIBLINGS: - raise FreezeListError(f"A MerkleProof needs {PROOF_SIBLINGS} siblings, got {len(self.siblings)}") - if self.leaf_index < 0 or any(s < 0 for s in self.siblings): + def __init__(self, siblings: Sequence[int | str], leaf_index: int | str) -> None: + values = tuple(_field_value(s) for s in siblings) + if len(values) != PROOF_SIBLINGS: + raise FreezeListError(f"A MerkleProof needs {PROOF_SIBLINGS} siblings, got {len(values)}") + index = _u32_value(leaf_index) + if index < 0 or any(v < 0 for v in values): raise FreezeListError("MerkleProof values must be non-negative") + object.__setattr__(self, "siblings", values) + object.__setattr__(self, "leaf_index", index) + + @property + def sibling_literals(self) -> tuple[str, ...]: + """The siblings as ``"…field"`` literals.""" + return tuple(f"{s}field" for s in self.siblings) def __str__(self) -> str: - return "{ siblings: [" + ", ".join(f"{s}field" for s in self.siblings) + f"], leaf_index: {self.leaf_index}u32 }}" + return "{ siblings: [" + ", ".join(self.sibling_literals) + f"], leaf_index: {self.leaf_index}u32 }}" @classmethod def from_string(cls, text: str) -> "MerkleProofInput": diff --git a/sdk/python/tests/test_compliance.py b/sdk/python/tests/test_compliance.py index f1a3c5b5..f82245f7 100644 --- a/sdk/python/tests/test_compliance.py +++ b/sdk/python/tests/test_compliance.py @@ -120,6 +120,21 @@ def test_merkle_proof_round_trips_through_its_literal(): MerkleProofPair.from_string(f"[{EMPTY_ONE}]") +def test_merkle_proof_input_accepts_field_literals_as_well_as_ints(): + ints = MerkleProofInput((A_FIELD,) + (0,) * 15, 1) + strs = MerkleProofInput((f"{A_FIELD}field",) + ("0field",) * 15, "1u32") # the TypeScript SDK's form + mixed = MerkleProofInput([A_FIELD] + [" 0field "] * 15, 1) + assert ints == strs == mixed and hash(ints) == hash(strs) + assert strs.siblings[0] == A_FIELD and strs.leaf_index == 1 + assert strs.sibling_literals == (f"{A_FIELD}field",) + ("0field",) * 15 + assert str(strs) == str(ints) and MerkleProofInput.from_string(str(strs)) == strs + with pytest.raises(FreezeListError, match="field literal"): + MerkleProofInput(("1",) + ("0field",) * 15, 1) + with pytest.raises(FreezeListError, match="u32 literal"): + MerkleProofInput((0,) * 16, "1") + assert fl.format_merkle_proof((["0field"] * 16, "1u32"), ([0] * 16, 1)) == EMPTY_MERKLE_PROOF_PAIR + + def test_format_merkle_proof_pair_matches_the_raw_form(): one = MerkleProofInput((0,) * 16, 1) assert fl.format_merkle_proof_pair(MerkleProofPair(one, one)) == EMPTY_MERKLE_PROOF_PAIR From b0248ab537eb55e6b7c6b97d5008090041620f14 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 04:20:28 -0400 Subject: [PATCH 05/12] fix(bridge): rotate the BAT, USDG and ZEC Aleo warp-route owners (veil #193) The three warp programs changed token_owner on 2026-10-02; the stale shared owner made every Aleo-origin transfer fail the on-chain app_metadata check. Registry version is unchanged (data fix, as upstream). Owners verified against the live app_metadata mappings; fixture mirrors veil's reviewed metadata with a regression test over the five outbound routes. --- .../python/aleo_bridge/_registry_data.py | 25 +++++++++++++------ .../fixtures/warp-route-app-metadata.json | 18 +++++++++++++ bridge-sdk/tests/test_hyperlane.py | 14 +++++++++++ bridge-sdk/tests/test_registry.py | 12 +++++++-- 4 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 bridge-sdk/tests/fixtures/warp-route-app-metadata.json diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py index 788f6fc2..53e03b12 100644 --- a/bridge-sdk/python/aleo_bridge/_registry_data.py +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -1,5 +1,6 @@ -"""Verbatim port of veil ``packages/bridge/src/registry/default.ts`` (PR #169 head ac7f718, registry version -2026-09-30.hyperlane-bat-usdg-zec.1). Plain dicts only — ``registry.py`` turns them into dataclasses. +"""Verbatim port of veil ``packages/bridge/src/registry/default.ts`` (PR #169 head ac7f718 plus the PR #193 +owner rotation at afc5601, registry version 2026-09-30.hyperlane-bat-usdg-zec.1). Plain dicts only — +``registry.py`` turns them into dataclasses. Every key keeps veil's camelCase spelling so the brief and veil tests stay the source of truth.""" from __future__ import annotations @@ -368,16 +369,21 @@ def _aleo_hyperlane_placeholders(program: str, destination_domain: int) -> dict: } +# _source: ProvableHQ/veil packages/bridge/src/registry/default.ts @ afc5601c98dfa28e76a6c619825856f1bdd6e7d9 +# (PR #193). Each program's owner rotated on 2026-10-02; the on-chain app_metadata asserts equality, so a +# stale owner fails every outbound transfer. Reviewed 2026-10-21 against +# https://edge.provable.com/api/v2/mainnet/program//mapping/app_metadata/true +# (tests/fixtures/warp-route-app-metadata.json). def _new_warp_route_aleo_app_metadata(program: str, token_id: str, local_decimals: int, remote_decimals: int, - hyperlane_config_source: str) -> dict: + hyperlane_config_source: str, token_owner: str) -> dict: return { "aleoAppMetadataVerified": True, "aleoProgramSource": f"https://explorer.provable.com/program/{program}", "aleoAppMetadataSource": f"https://api.explorer.provable.com/v2/mainnet/program/{program}/mapping/app_metadata/true", - "aleoAppMetadataReviewedAt": "2026-09-30", + "aleoAppMetadataReviewedAt": "2026-10-21", "aleoProgramEdition": 0, "aleoTokenType": "1", - "aleoTokenOwner": "aleo1mx0tldt5qsqymn5a3whnmf9rx2whp837jjn0tvqgxqf86zg6dvyqnc8spm", + "aleoTokenOwner": token_owner, "aleoIsm": ZERO_ADDRESS, "aleoHook": ZERO_ADDRESS, "aleoTokenId": token_id, @@ -390,15 +396,18 @@ def _new_warp_route_aleo_app_metadata(program: str, token_id: str, local_decimal ALEO_BAT_APP_METADATA = _new_warp_route_aleo_app_metadata( "hyp_warp_token_bat_v2.aleo", "8193754087214450113583165652573869677861364321518267024225788045720787201438field", 18, 18, - BAT_HYPERLANE_CONFIG_SOURCE) + BAT_HYPERLANE_CONFIG_SOURCE, + "aleo1x7gswtgsj4hj9ntckt39wmfrnvr60eje5feyak2pnpfghuxh8v9qmxv5da") ALEO_USDG_APP_METADATA = _new_warp_route_aleo_app_metadata( "hyp_warp_token_usdg_v2.aleo", "4364459415416156846201796031612641041087412365006702457109915656601258641029field", 6, 6, - USDG_HYPERLANE_CONFIG_SOURCE) + USDG_HYPERLANE_CONFIG_SOURCE, + "aleo1lad36r82wn80fs9vzmyykfgkne6045auy9dy50rppck98rvuk58s70xyfh") ALEO_ZEC_APP_METADATA = _new_warp_route_aleo_app_metadata( "hyp_warp_token_zec_v2.aleo", "220414605002186903241059728372192608429362177759171998609092499027319866844field", 8, 8, - ZEC_HYPERLANE_CONFIG_SOURCE) + ZEC_HYPERLANE_CONFIG_SOURCE, + "aleo1yv5gy4l5g82dcu47260t6lpls4xvg29zpg2m06jdwvdtm8m06gzsysx6dy") _ALLOWANCES = { "aleoAllowanceSpendersVerified": True, diff --git a/bridge-sdk/tests/fixtures/warp-route-app-metadata.json b/bridge-sdk/tests/fixtures/warp-route-app-metadata.json new file mode 100644 index 00000000..0c23cd2a --- /dev/null +++ b/bridge-sdk/tests/fixtures/warp-route-app-metadata.json @@ -0,0 +1,18 @@ +{ + "_source": "ProvableHQ/veil packages/bridge/test/fixtures/warp-route-app-metadata.json @ afc5601c98dfa28e76a6c619825856f1bdd6e7d9 (PR #193)", + "hyp_warp_token_bat_v2.aleo": { + "source": "https://edge.provable.com/api/v2/mainnet/program/hyp_warp_token_bat_v2.aleo/mapping/app_metadata/true", + "reviewedAt": "2026-10-21", + "metadata": "{\n token_type: 1u8,\n token_owner: aleo1x7gswtgsj4hj9ntckt39wmfrnvr60eje5feyak2pnpfghuxh8v9qmxv5da,\n ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n token_id: 8193754087214450113583165652573869677861364321518267024225788045720787201438field,\n local_decimals: 18u8,\n remote_decimals: 18u8\n}" + }, + "hyp_warp_token_usdg_v2.aleo": { + "source": "https://edge.provable.com/api/v2/mainnet/program/hyp_warp_token_usdg_v2.aleo/mapping/app_metadata/true", + "reviewedAt": "2026-10-21", + "metadata": "{\n token_type: 1u8,\n token_owner: aleo1lad36r82wn80fs9vzmyykfgkne6045auy9dy50rppck98rvuk58s70xyfh,\n ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n token_id: 4364459415416156846201796031612641041087412365006702457109915656601258641029field,\n local_decimals: 6u8,\n remote_decimals: 6u8\n}" + }, + "hyp_warp_token_zec_v2.aleo": { + "source": "https://edge.provable.com/api/v2/mainnet/program/hyp_warp_token_zec_v2.aleo/mapping/app_metadata/true", + "reviewedAt": "2026-10-21", + "metadata": "{\n token_type: 1u8,\n token_owner: aleo1yv5gy4l5g82dcu47260t6lpls4xvg29zpg2m06jdwvdtm8m06gzsysx6dy,\n ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc,\n token_id: 220414605002186903241059728372192608429362177759171998609092499027319866844field,\n local_decimals: 8u8,\n remote_decimals: 8u8\n}" + } +} diff --git a/bridge-sdk/tests/test_hyperlane.py b/bridge-sdk/tests/test_hyperlane.py index f1f5fd46..96d69cf1 100644 --- a/bridge-sdk/tests/test_hyperlane.py +++ b/bridge-sdk/tests/test_hyperlane.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + import pytest from aleo_bridge import hyperlane as hl @@ -34,6 +37,17 @@ def _inputs(bridge, route_id, recipient, amount, gas=8174147): decimals=(route.metadata["aleoLocalDecimals"], route.metadata["aleoRemoteDecimals"])) +# veil PR #193: the reviewed on-chain app_metadata of each BAT / USDG / ZEC warp program after the +# 2026-10-02 owner rotation. transfer_remote's first input must equal it or the finalize rejects. +WARP_APP_METADATA = json.loads((Path(__file__).parent / "fixtures" / "warp-route-app-metadata.json").read_text()) + + +@pytest.mark.parametrize("asset,route_id,program,recipient,amount", ROUTES[4:]) +def test_bat_usdg_zec_app_metadata_matches_reviewed_chain_state(bridge, asset, route_id, program, recipient, amount): + inputs = _inputs(bridge, route_id, recipient, amount, gas=1) + assert "".join(inputs[0].split()) == "".join(WARP_APP_METADATA[program]["metadata"].split()) + + def test_pure_helpers(): assert hl.parse_gas_config(ETH_GAS_CONFIG) == {"gas_overhead": 159337, "exchange_rate": 402, "gas_price": 1000000000} with pytest.raises(ConfigurationError, match="malformed"): diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py index e0d0fb79..0e4ac7ed 100644 --- a/bridge-sdk/tests/test_registry.py +++ b/bridge-sdk/tests/test_registry.py @@ -420,8 +420,16 @@ def test_bat_usdg_zec_aleo_withdrawals_are_reviewed_not_placeholders(): assert m["aleoRouterProgram"] == program and m["aleoDestinationDomain"] == domain and m["aleoRemoteRouterGas"] == gas assert m["aleoTokenId"] == token_id and (m["aleoLocalDecimals"], m["aleoRemoteDecimals"]) == decimals assert m["aleoPlaceholderConfiguration"] is False and m["aleoWithdrawalReviewedAt"] == "2026-09-30" - assert m["aleoAppMetadataReviewedAt"] == m["aleoRemoteRouterReviewedAt"] == "2026-09-30" - assert m["aleoTokenOwner"] == "aleo1mx0tldt5qsqymn5a3whnmf9rx2whp837jjn0tvqgxqf86zg6dvyqnc8spm" + assert m["aleoRemoteRouterReviewedAt"] == "2026-09-30" + # veil PR #193: owners rotated on 2026-10-02 (one per program); the stale shared owner failed the + # on-chain app_metadata equality check on every outbound transfer. + assert m["aleoAppMetadataReviewedAt"] == "2026-10-21" + assert m["aleoTokenOwner"] == { + "hyp_warp_token_bat_v2.aleo": "aleo1x7gswtgsj4hj9ntckt39wmfrnvr60eje5feyak2pnpfghuxh8v9qmxv5da", + "hyp_warp_token_usdg_v2.aleo": "aleo1lad36r82wn80fs9vzmyykfgkne6045auy9dy50rppck98rvuk58s70xyfh", + "hyp_warp_token_zec_v2.aleo": "aleo1yv5gy4l5g82dcu47260t6lpls4xvg29zpg2m06jdwvdtm8m06gzsysx6dy", + }[program] + assert m["aleoTokenOwner"] != "aleo1mx0tldt5qsqymn5a3whnmf9rx2whp837jjn0tvqgxqf86zg6dvyqnc8spm" # the pre-rotation owner assert m["aleoRemoteRouterSource"] == f"https://api.explorer.provable.com/v2/mainnet/program/{program}/mapping/remote_routers/{domain}u32" assert m["aleoProgramEdition"] == 0 and m["aleoTokenType"] == "1" assert m["aleoAllowanceSpender0"] == "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" From 6d92751cb20da64787d15dc0c76e43f8b169e17b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 04:25:13 -0400 Subject: [PATCH 06/12] refactor(compliance): rename ProofBundle to ComplianceProofs One [MerkleProof; 2] argument as a caller supplies it (literal, MerkleProofPair, or a pair of MerkleProofInput); the name follows veil's ComplianceProofs. --- bridge-sdk/python/aleo_bridge/AGENTS.md | 6 +++--- bridge-sdk/python/aleo_bridge/__init__.py | 4 ++-- bridge-sdk/python/aleo_bridge/client.py | 6 +++--- bridge-sdk/python/aleo_bridge/freezelist.py | 6 +++--- bridge-sdk/python/aleo_bridge/privacy.py | 4 ++-- bridge-sdk/python/aleo_bridge/xreserve.py | 6 +++--- sdk/python/aleo/__init__.py | 2 +- sdk/python/aleo/compliance.py | 14 +++++++------- .../python/aleo_shield_swap/__init__.py | 2 +- .../python/aleo_shield_swap/compliance.py | 12 ++++++------ 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index 90ed9f8d..c03392b9 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -79,7 +79,7 @@ solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. -### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -276,7 +276,7 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. ``asset`` is an asset ref, a ``Route`` or a route id (BAT/USDG need the route: two destinations). -### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` Burn USDCx for USDC on the selected EVM route (Ethereum by default). @@ -302,7 +302,7 @@ One Circle request for *message_hash*; ``None`` while pending (404). -### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` Move a private record into the public balance; see ``privacy.unshield``. diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 711f1093..b4aa25a7 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -29,7 +29,7 @@ from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .eth import Ethereum, EthModule # noqa: E402 -from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProofInput, MerkleProofPair, ProofBundle # noqa: E402 +from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProofInput, MerkleProofPair, ComplianceProofs # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 from .lifecycle import prepare # noqa: E402 @@ -51,7 +51,7 @@ "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", - "MerkleProofInput", "MerkleProofPair", "ProofBundle", + "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointLoadResult", "CheckpointProblem", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 9ae47baa..6ac7b34b 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -20,7 +20,7 @@ from .cctp import CctpModule from .errors import BridgeError, ConfigurationError, UnsupportedRouteError from .eth import Ethereum, EthModule -from .freezelist import FreezeList, ProofBundle +from .freezelist import FreezeList, ComplianceProofs from .hyperlane import HyperlaneModule from .privacy import PrivacyModule from .profile import DEFAULT_ENDPOINT, Profile @@ -268,7 +268,7 @@ def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = return self.privacy.shield(asset, amount=amount, amount_atomic=amount_atomic, recipient=recipient) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: """Move a private record into the public balance; see ``privacy.unshield``. @@ -377,7 +377,7 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N mint_mode=mint_mode, secret_nonce=secret_nonce, **({"cctp": cctp} if cctp is not None else {})) def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: str | None = None, - record: str | None = None, compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, + record: str | None = None, compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0): diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index 748ffd95..06f1e3dd 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -25,7 +25,7 @@ FrozenAddressError, MerkleProofInput, MerkleProofPair, - ProofBundle, + ComplianceProofs, address_to_field_int, build_tree, format_merkle_proof, @@ -45,7 +45,7 @@ FREEZE_LIST_PROGRAMS = {program: freezelist for program, freezelist in KNOWN_TOKEN_FREEZELISTS.items() if freezelist} -def literal_compliance_proof(compliance_proofs: ProofBundle | tuple[ProofBundle] | None, merkle_proof: str | None) -> str | None: +def literal_compliance_proof(compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None, merkle_proof: str | None) -> str | None: """The single ``[MerkleProof; 2]`` literal a bridge call was given, or ``None`` to compute one. Raises: @@ -150,6 +150,6 @@ def exclusion_proof(self, address: str, program: str) -> str: __all__ = ["CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_PROGRAMS", "FREEZE_LIST_ROOT_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", - "FreezeListError", "FrozenAddressError", "MerkleProofInput", "MerkleProofPair", "ProofBundle", + "FreezeListError", "FrozenAddressError", "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", "leaf_indices", "literal_compliance_proof", "sibling_path"] diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index 49b9ef33..911cbccd 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -5,7 +5,7 @@ import re from typing import TYPE_CHECKING, Any -from aleo.compliance import ProofBundle +from aleo.compliance import ComplianceProofs from ._calls import AleoCall from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError @@ -107,7 +107,7 @@ def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: return self._bridge._call(privacy.program, function, inputs, build) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: """Move a private record into the public balance. diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index 8daa1097..82a9476e 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any import requests -from aleo.compliance import ProofBundle +from aleo.compliance import ComplianceProofs from . import encoding as enc from ._calls import AleoCall @@ -138,7 +138,7 @@ def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, reci return route.meta_str("bridgeProgram"), function, [amount_lit, domain_lit, recipient_lit] def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", - record: str | None = None, compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, + record: str | None = None, compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, route: Route | None = None) -> AleoCall[BurnReceipt]: """Burn USDCx for USDC on the selected EVM route (Ethereum by default). @@ -157,7 +157,7 @@ def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", record: str | None = None, - compliance_proofs: ProofBundle | tuple[ProofBundle] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, route: Route | None = None, withdrawal_fee_atomic: int | None = None) -> AleoCall[BurnReceipt]: """Build a burn with the lifecycle's validated execution-time estimate, if supplied.""" if mode not in BURN_MODES: diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index b07e00a0..116acd6b 100644 --- a/sdk/python/aleo/__init__.py +++ b/sdk/python/aleo/__init__.py @@ -37,7 +37,7 @@ FrozenAddressError as FrozenAddressError, MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, - ProofBundle as ProofBundle, + ComplianceProofs as ComplianceProofs, ComplianceProofContext as ComplianceProofContext, ) from .facade.errors import ( diff --git a/sdk/python/aleo/compliance.py b/sdk/python/aleo/compliance.py index 23d2a0c9..0c822100 100644 --- a/sdk/python/aleo/compliance.py +++ b/sdk/python/aleo/compliance.py @@ -266,7 +266,7 @@ def from_string(cls, text: str) -> "MerkleProofPair": #: One compliance proof argument as a caller may supply it: the ``[MerkleProof; 2]`` #: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProofInput` paths. -ProofBundle = str | MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput] +ComplianceProofs = str | MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput] def format_merkle_proof_pair(pair: MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput]) -> str: @@ -278,7 +278,7 @@ def format_merkle_proof_pair(pair: MerkleProofPair | tuple[MerkleProofInput, Mer raise FreezeListError("A proof pair is a MerkleProofPair or a (MerkleProofInput, MerkleProofInput) tuple") -def _bundle_literal(bundle: ProofBundle) -> str: +def _bundle_literal(bundle: ComplianceProofs) -> str: """The ``[MerkleProof; 2]`` literal for one caller-supplied bundle, validated.""" if isinstance(bundle, str): text = bundle.strip() @@ -295,7 +295,7 @@ def literal_compliance_proof(compliance_proofs: Any, legacy: Any = None, *, lega """The one ``[MerkleProof; 2]`` literal a call was given, or ``None`` when the SDK should build it. Normalises the proof arguments of a call that takes exactly one bundle: - a :data:`ProofBundle` or a one-element tuple of one under + a :data:`ComplianceProofs` or a one-element tuple of one under ``compliance_proofs``, and the literal string the call accepted before under its old keyword. Reads nothing from the chain. @@ -315,11 +315,11 @@ def literal_compliance_proof(compliance_proofs: Any, legacy: Any = None, *, lega if isinstance(compliance_proofs, tuple): items = cast(tuple[Any, ...], compliance_proofs) if _is_path_tuple(items): - return _bundle_literal(cast(ProofBundle, items)) + return _bundle_literal(cast(ComplianceProofs, items)) if len(items) != 1: raise FreezeListError(f"This operation requires exactly one compliance proof bundle; received {len(items)}") - return _bundle_literal(cast(ProofBundle, items[0])) - return _bundle_literal(cast(ProofBundle, compliance_proofs)) + return _bundle_literal(cast(ComplianceProofs, items[0])) + return _bundle_literal(cast(ComplianceProofs, compliance_proofs)) def _is_path_tuple(value: Any) -> bool: @@ -713,7 +713,7 @@ def _network_module(network: str) -> Any: "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_ROOT_MAPPING", "KNOWN_TOKEN_FREEZELISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", "AsyncFreezeListProofs", "FreezeListError", "FreezeListProofs", "FrozenAddressError", - "ComplianceProofContext", "MerkleProofInput", "MerkleProofPair", "ProofBundle", + "ComplianceProofContext", "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", "address_to_field_int", "async_freezelist_program", "build_tree", "format_merkle_proof", "format_merkle_proof_pair", "freezelist_from_imports", "freezelist_program", "generate_freezelist_proof", "generate_leaves", "hash_two", "leaf_indices", "literal_compliance_proof", "sibling_path", diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 0e7c0466..7f32c3d2 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -29,7 +29,7 @@ MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, MintProofs as MintProofs, - ProofBundle as ProofBundle, + ComplianceProofs as ComplianceProofs, RebalanceProofs as RebalanceProofs, SHIELD_SWAP_FREEZELIST as SHIELD_SWAP_FREEZELIST, SwapProofs as SwapProofs, diff --git a/shield-swap-sdk/python/aleo_shield_swap/compliance.py b/shield-swap-sdk/python/aleo_shield_swap/compliance.py index 9d03472b..5a72ae22 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/compliance.py +++ b/shield-swap-sdk/python/aleo_shield_swap/compliance.py @@ -36,14 +36,14 @@ FreezeListError, MerkleProofInput, MerkleProofPair, - ProofBundle, + ComplianceProofs, ComplianceProofContext, literal_compliance_proof, ) SHIELD_SWAP_FREEZELIST = "shield_swap_freezelist.aleo" -_B = ProofBundle +_B = ComplianceProofs #: ``swap``: one sender bundle when the input token is wrapped, none otherwise. SwapProofs = Union[tuple[()], tuple[_B]] #: ``claim_swap_output``: the AMM signer bundle, then a receiver bundle per wrapped leg paid (output, then refund). @@ -177,7 +177,7 @@ def rebalance_slots(w0: bool, w1: bool, funded0: bool, funded1: bool, signer: st """Maps a wrapped token id to the freeze-list program its wrapper checks (``None`` when it ignores the proof).""" -def supplied_literals(action: str, supplied: Sequence[ProofBundle], slots: Sequence[Slot]) -> list[str]: +def supplied_literals(action: str, supplied: Sequence[ComplianceProofs], slots: Sequence[Slot]) -> list[str]: """Validate caller-supplied bundles against the planned slots and return their literals in order. Raises: @@ -196,13 +196,13 @@ def supplied_literals(action: str, supplied: Sequence[ProofBundle], slots: Seque raise ValueError(f"{action}: {exc}") from exc -def _literal(bundle: ProofBundle) -> str: +def _literal(bundle: ComplianceProofs) -> str: literal = literal_compliance_proof(bundle) assert literal is not None # a bundle is never None return literal -def _looks_like_one_bundle(value: Sequence[ProofBundle]) -> bool: +def _looks_like_one_bundle(value: Sequence[ComplianceProofs]) -> bool: return len(value) == 2 and all(isinstance(p, MerkleProofInput) for p in value) @@ -233,7 +233,7 @@ def check_proof_arguments(compliance_proofs: object, wrapper_proofs: Optional[st __all__ = [ "EMPTY_MERKLE_PROOF_PAIR", "SHIELD_SWAP_FREEZELIST", "ClaimProofs", "CollectProofs", "IncreaseProofs", "MintProofs", "RebalanceProofs", "SwapProofs", - "MerkleProofInput", "MerkleProofPair", "ProofBundle", "Slot", + "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", "Slot", "check_proof_arguments", "claim_slots", "collect_slots", "increase_slots", "merge_legacy_wrapper_proofs", "mint_slots", "planned_proof_slots", "rebalance_slots", "supplied_literals", "swap_slots", ] From aa4cee1ea25e46af9428f0a466e4a764ab702032 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 04:35:57 -0400 Subject: [PATCH 07/12] refactor(compliance): ComplianceProof for one proof, ComplianceProofs for the argument ComplianceProof is one [MerkleProof; 2] value (literal, MerkleProofPair, or a pair of MerkleProofInput). Each SDK package now defines ComplianceProofs as its calls' whole compliance_proofs argument, as veil does: the bridge's is one proof bare or in a one-tuple or None; Shield Swap's is the union of the per-method tuple aliases or None. --- bridge-sdk/AGENTS.md | 6 +++--- bridge-sdk/python/aleo_bridge/AGENTS.md | 6 +++--- bridge-sdk/python/aleo_bridge/__init__.py | 4 ++-- bridge-sdk/python/aleo_bridge/client.py | 6 +++--- bridge-sdk/python/aleo_bridge/freezelist.py | 10 +++++++--- bridge-sdk/python/aleo_bridge/privacy.py | 4 ++-- bridge-sdk/python/aleo_bridge/xreserve.py | 7 +++---- sdk/python/aleo/__init__.py | 2 +- sdk/python/aleo/compliance.py | 18 ++++++++++-------- .../python/aleo_shield_swap/__init__.py | 3 ++- .../python/aleo_shield_swap/compliance.py | 15 +++++++++------ 11 files changed, 45 insertions(+), 36 deletions(-) diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index 90ed9f8d..fb660182 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -79,7 +79,7 @@ solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. -### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -276,7 +276,7 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. ``asset`` is an asset ref, a ``Route`` or a route id (BAT/USDG need the route: two destinations). -### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` Burn USDCx for USDC on the selected EVM route (Ethereum by default). @@ -302,7 +302,7 @@ One Circle request for *message_hash*; ``None`` while pending (404). -### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ProofBundle | tuple[ProofBundle] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` Move a private record into the public balance; see ``privacy.unshield``. diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index c03392b9..fb660182 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -79,7 +79,7 @@ solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. -### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` Commit funds on the source chain for ``quote.plan``; returns ``Progress``. @@ -276,7 +276,7 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. ``asset`` is an asset ref, a ``Route`` or a route id (BAT/USDG need the route: two destinations). -### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` Burn USDCx for USDC on the selected EVM route (Ethereum by default). @@ -302,7 +302,7 @@ One Circle request for *message_hash*; ``None`` while pending (404). -### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs | tuple[ComplianceProofs] | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` Move a private record into the public balance; see ``privacy.unshield``. diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index b4aa25a7..ba822042 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -29,7 +29,7 @@ from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .eth import Ethereum, EthModule # noqa: E402 -from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList, MerkleProofInput, MerkleProofPair, ComplianceProofs # noqa: E402 +from .freezelist import EMPTY_MERKLE_PROOF_PAIR, ComplianceProof, ComplianceProofs, FreezeList, MerkleProofInput, MerkleProofPair # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 from .lifecycle import prepare # noqa: E402 @@ -51,7 +51,7 @@ "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", - "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", + "ComplianceProof", "ComplianceProofs", "MerkleProofInput", "MerkleProofPair", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointLoadResult", "CheckpointProblem", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 6ac7b34b..68880c6d 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -20,7 +20,7 @@ from .cctp import CctpModule from .errors import BridgeError, ConfigurationError, UnsupportedRouteError from .eth import Ethereum, EthModule -from .freezelist import FreezeList, ComplianceProofs +from .freezelist import ComplianceProofs, FreezeList from .hyperlane import HyperlaneModule from .privacy import PrivacyModule from .profile import DEFAULT_ENDPOINT, Profile @@ -268,7 +268,7 @@ def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = return self.privacy.shield(asset, amount=amount, amount_atomic=amount_atomic, recipient=recipient) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs = None, merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: """Move a private record into the public balance; see ``privacy.unshield``. @@ -377,7 +377,7 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N mint_mode=mint_mode, secret_nonce=secret_nonce, **({"cctp": cctp} if cctp is not None else {})) def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: str | None = None, - record: str | None = None, compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, + record: str | None = None, compliance_proofs: ComplianceProofs = None, merkle_proof: str | None = None, gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0): diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index 06f1e3dd..080ca465 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -25,7 +25,7 @@ FrozenAddressError, MerkleProofInput, MerkleProofPair, - ComplianceProofs, + ComplianceProof, address_to_field_int, build_tree, format_merkle_proof, @@ -41,11 +41,15 @@ if TYPE_CHECKING: # pragma: no cover from .client import Bridge +#: The ``compliance_proofs`` argument of a bridge call, which takes exactly one proof: a +#: :data:`ComplianceProof` bare or in a one-tuple, or ``None`` to have the SDK build it. +ComplianceProofs = ComplianceProof | tuple[ComplianceProof] | None + #: Token program -> its freeze-list program, for when ``Bridge.program(token).imports`` is unavailable. FREEZE_LIST_PROGRAMS = {program: freezelist for program, freezelist in KNOWN_TOKEN_FREEZELISTS.items() if freezelist} -def literal_compliance_proof(compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None, merkle_proof: str | None) -> str | None: +def literal_compliance_proof(compliance_proofs: ComplianceProofs, merkle_proof: str | None) -> str | None: """The single ``[MerkleProof; 2]`` literal a bridge call was given, or ``None`` to compute one. Raises: @@ -150,6 +154,6 @@ def exclusion_proof(self, address: str, program: str) -> str: __all__ = ["CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_PROGRAMS", "FREEZE_LIST_ROOT_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", - "FreezeListError", "FrozenAddressError", "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", + "FreezeListError", "FrozenAddressError", "MerkleProofInput", "MerkleProofPair", "ComplianceProof", "ComplianceProofs", "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", "leaf_indices", "literal_compliance_proof", "sibling_path"] diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index 911cbccd..caf202a7 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -5,7 +5,7 @@ import re from typing import TYPE_CHECKING, Any -from aleo.compliance import ComplianceProofs +from .freezelist import ComplianceProofs from ._calls import AleoCall from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError @@ -107,7 +107,7 @@ def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: return self._bridge._call(privacy.program, function, inputs, build) def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, - compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs = None, merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: """Move a private record into the public balance. diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index 82a9476e..e1237eae 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any import requests -from aleo.compliance import ComplianceProofs from . import encoding as enc from ._calls import AleoCall from ._keccak import keccak256 from .circle import CircleClient -from .freezelist import literal_compliance_proof +from .freezelist import ComplianceProofs, literal_compliance_proof from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Route @@ -138,7 +137,7 @@ def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, reci return route.meta_str("bridgeProgram"), function, [amount_lit, domain_lit, recipient_lit] def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", - record: str | None = None, compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, + record: str | None = None, compliance_proofs: ComplianceProofs = None, merkle_proof: str | None = None, route: Route | None = None) -> AleoCall[BurnReceipt]: """Burn USDCx for USDC on the selected EVM route (Ethereum by default). @@ -157,7 +156,7 @@ def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", record: str | None = None, - compliance_proofs: ComplianceProofs | tuple[ComplianceProofs] | None = None, merkle_proof: str | None = None, + compliance_proofs: ComplianceProofs = None, merkle_proof: str | None = None, route: Route | None = None, withdrawal_fee_atomic: int | None = None) -> AleoCall[BurnReceipt]: """Build a burn with the lifecycle's validated execution-time estimate, if supplied.""" if mode not in BURN_MODES: diff --git a/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index 116acd6b..c1b0aa02 100644 --- a/sdk/python/aleo/__init__.py +++ b/sdk/python/aleo/__init__.py @@ -37,7 +37,7 @@ FrozenAddressError as FrozenAddressError, MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, - ComplianceProofs as ComplianceProofs, + ComplianceProof as ComplianceProof, ComplianceProofContext as ComplianceProofContext, ) from .facade.errors import ( diff --git a/sdk/python/aleo/compliance.py b/sdk/python/aleo/compliance.py index 0c822100..d495806e 100644 --- a/sdk/python/aleo/compliance.py +++ b/sdk/python/aleo/compliance.py @@ -264,9 +264,11 @@ def from_string(cls, text: str) -> "MerkleProofPair": return cls(MerkleProofInput.from_string(parts[0]), MerkleProofInput.from_string(parts[1])) -#: One compliance proof argument as a caller may supply it: the ``[MerkleProof; 2]`` +#: One compliance proof as a caller may supply it: the ``[MerkleProof; 2]`` #: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProofInput` paths. -ComplianceProofs = str | MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput] +#: A call's ``compliance_proofs`` argument holds one or more of these (see the +#: ``ComplianceProofs`` alias of each SDK package). +ComplianceProof = str | MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput] def format_merkle_proof_pair(pair: MerkleProofPair | tuple[MerkleProofInput, MerkleProofInput]) -> str: @@ -278,7 +280,7 @@ def format_merkle_proof_pair(pair: MerkleProofPair | tuple[MerkleProofInput, Mer raise FreezeListError("A proof pair is a MerkleProofPair or a (MerkleProofInput, MerkleProofInput) tuple") -def _bundle_literal(bundle: ComplianceProofs) -> str: +def _bundle_literal(bundle: ComplianceProof) -> str: """The ``[MerkleProof; 2]`` literal for one caller-supplied bundle, validated.""" if isinstance(bundle, str): text = bundle.strip() @@ -295,7 +297,7 @@ def literal_compliance_proof(compliance_proofs: Any, legacy: Any = None, *, lega """The one ``[MerkleProof; 2]`` literal a call was given, or ``None`` when the SDK should build it. Normalises the proof arguments of a call that takes exactly one bundle: - a :data:`ComplianceProofs` or a one-element tuple of one under + a :data:`ComplianceProof` or a one-element tuple of one under ``compliance_proofs``, and the literal string the call accepted before under its old keyword. Reads nothing from the chain. @@ -315,11 +317,11 @@ def literal_compliance_proof(compliance_proofs: Any, legacy: Any = None, *, lega if isinstance(compliance_proofs, tuple): items = cast(tuple[Any, ...], compliance_proofs) if _is_path_tuple(items): - return _bundle_literal(cast(ComplianceProofs, items)) + return _bundle_literal(cast(ComplianceProof, items)) if len(items) != 1: raise FreezeListError(f"This operation requires exactly one compliance proof bundle; received {len(items)}") - return _bundle_literal(cast(ComplianceProofs, items[0])) - return _bundle_literal(cast(ComplianceProofs, compliance_proofs)) + return _bundle_literal(cast(ComplianceProof, items[0])) + return _bundle_literal(cast(ComplianceProof, compliance_proofs)) def _is_path_tuple(value: Any) -> bool: @@ -713,7 +715,7 @@ def _network_module(network: str) -> Any: "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", "FREEZE_LIST_ROOT_MAPPING", "KNOWN_TOKEN_FREEZELISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", "AsyncFreezeListProofs", "FreezeListError", "FreezeListProofs", "FrozenAddressError", - "ComplianceProofContext", "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", + "ComplianceProofContext", "MerkleProofInput", "MerkleProofPair", "ComplianceProof", "address_to_field_int", "async_freezelist_program", "build_tree", "format_merkle_proof", "format_merkle_proof_pair", "freezelist_from_imports", "freezelist_program", "generate_freezelist_proof", "generate_leaves", "hash_two", "leaf_indices", "literal_compliance_proof", "sibling_path", diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 7f32c3d2..37591970 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -25,11 +25,12 @@ from .compliance import ( ClaimProofs as ClaimProofs, CollectProofs as CollectProofs, + ComplianceProof as ComplianceProof, + ComplianceProofs as ComplianceProofs, IncreaseProofs as IncreaseProofs, MerkleProofInput as MerkleProofInput, MerkleProofPair as MerkleProofPair, MintProofs as MintProofs, - ComplianceProofs as ComplianceProofs, RebalanceProofs as RebalanceProofs, SHIELD_SWAP_FREEZELIST as SHIELD_SWAP_FREEZELIST, SwapProofs as SwapProofs, diff --git a/shield-swap-sdk/python/aleo_shield_swap/compliance.py b/shield-swap-sdk/python/aleo_shield_swap/compliance.py index 5a72ae22..a0078f1a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/compliance.py +++ b/shield-swap-sdk/python/aleo_shield_swap/compliance.py @@ -36,14 +36,14 @@ FreezeListError, MerkleProofInput, MerkleProofPair, - ComplianceProofs, + ComplianceProof, ComplianceProofContext, literal_compliance_proof, ) SHIELD_SWAP_FREEZELIST = "shield_swap_freezelist.aleo" -_B = ComplianceProofs +_B = ComplianceProof #: ``swap``: one sender bundle when the input token is wrapped, none otherwise. SwapProofs = Union[tuple[()], tuple[_B]] #: ``claim_swap_output``: the AMM signer bundle, then a receiver bundle per wrapped leg paid (output, then refund). @@ -57,6 +57,9 @@ #: ``rebalance_position``: a sender bundle per funded wrapped side, a receiver bundle per wrapped side, then AMM owner and withdrawal. RebalanceProofs = Union[tuple[_B, _B], tuple[_B, _B, _B], tuple[_B, _B, _B, _B], tuple[_B, _B, _B, _B, _B], tuple[_B, _B, _B, _B, _B, _B]] +#: The ``compliance_proofs`` argument of any Shield Swap write: the method's tuple of proofs in +#: input order, or ``None`` to have the SDK build them. +ComplianceProofs = Optional[Union[SwapProofs, ClaimProofs, MintProofs, IncreaseProofs, CollectProofs, RebalanceProofs]] SlotKind = Literal["amm", "wrapper"] @@ -177,7 +180,7 @@ def rebalance_slots(w0: bool, w1: bool, funded0: bool, funded1: bool, signer: st """Maps a wrapped token id to the freeze-list program its wrapper checks (``None`` when it ignores the proof).""" -def supplied_literals(action: str, supplied: Sequence[ComplianceProofs], slots: Sequence[Slot]) -> list[str]: +def supplied_literals(action: str, supplied: Sequence[ComplianceProof], slots: Sequence[Slot]) -> list[str]: """Validate caller-supplied bundles against the planned slots and return their literals in order. Raises: @@ -196,13 +199,13 @@ def supplied_literals(action: str, supplied: Sequence[ComplianceProofs], slots: raise ValueError(f"{action}: {exc}") from exc -def _literal(bundle: ComplianceProofs) -> str: +def _literal(bundle: ComplianceProof) -> str: literal = literal_compliance_proof(bundle) assert literal is not None # a bundle is never None return literal -def _looks_like_one_bundle(value: Sequence[ComplianceProofs]) -> bool: +def _looks_like_one_bundle(value: Sequence[ComplianceProof]) -> bool: return len(value) == 2 and all(isinstance(p, MerkleProofInput) for p in value) @@ -233,7 +236,7 @@ def check_proof_arguments(compliance_proofs: object, wrapper_proofs: Optional[st __all__ = [ "EMPTY_MERKLE_PROOF_PAIR", "SHIELD_SWAP_FREEZELIST", "ClaimProofs", "CollectProofs", "IncreaseProofs", "MintProofs", "RebalanceProofs", "SwapProofs", - "MerkleProofInput", "MerkleProofPair", "ComplianceProofs", "Slot", + "MerkleProofInput", "MerkleProofPair", "ComplianceProof", "ComplianceProofs", "Slot", "check_proof_arguments", "claim_slots", "collect_slots", "increase_slots", "merge_legacy_wrapper_proofs", "mint_slots", "planned_proof_slots", "rebalance_slots", "supplied_literals", "swap_slots", ] From 4b5ee49178488090b669514fedc57226336e396f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 05:05:07 -0400 Subject: [PATCH 08/12] docs: move Compliance to the end of both READMEs and rewrite it Explain how the private Merkle inputs establish freezelist compliance inside the transaction proof, list the functions that take proofs with their slots and subjects, then describe automatic generation, caching, errors, and the optional proof inputs. Wording follows veil #194. --- bridge-sdk/README.md | 116 ++++++++++++++++++--------- shield-swap-sdk/README.md | 161 ++++++++++++++++++++++++++------------ 2 files changed, 186 insertions(+), 91 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index d96c2765..441012fd 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -654,45 +654,6 @@ supplying `record=` avoids the hosted scanner. Shielding and unshielding each submit a separate Aleo transaction with a fee. A failed conversion does not reverse or repeat the bridge transfer. -### Compliance proofs for USDCx - -USDCx is an ARC-22 token: every private transfer checks that the signer is -not on the token's freeze list. A USDCx unshield and a private xReserve -withdrawal therefore take one `[MerkleProof; 2]` argument — a bundle of two -Merkle paths proving the signer's address is absent from -`usdcx_freezelist.aleo`. - -By default the SDK builds that bundle. It reads the list's current root, -fetches the published tree only when the root changed since the last call, -verifies every witness against the root, and reuses the result until the -list changes. While the list is empty (its state on both networks as of -2026-10-20) the fixed empty-tree bundle is used and nothing beyond the root is -read. A signer that is on the list gets `FrozenAddressError` before any fee is -paid. - -```python -receipt = bridge.unshield("aleo/usdcx", amount="2.5").delegate() # proof built by the SDK -progress = bridge.execute(quote.plan) # private burn: proof built by the SDK -``` - -Pass `compliance_proofs=` to use a bundle built elsewhere. The value is the -encoded literal, a `MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple; -a one-element tuple of any of these is accepted too. `merkle_proof=` is the -earlier name for the literal form and still works. - -```python -from aleo_bridge import MerkleProofInput, MerkleProofPair - -bundle = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1), - MerkleProofInput(siblings=("0field",) * 16, leaf_index="1u32")) # ints or literals -receipt = bridge.unshield("aleo/usdcx", amount="2.5", compliance_proofs=bundle).delegate() -``` - -`bridge.freezelist.exclusion_proof(address, "usdcx_stablecoin.aleo")` returns -the bundle for any address, and `bridge.freezelist.clear_cache()` forgets the -cached trees. The published tree comes from -`aleo.network.get_compliance_freeze_list(program)`. - ## Understand transfer costs A bridge transfer can incur transaction fees, relay costs, and provider fees @@ -972,3 +933,80 @@ Unknown versions and old version labels attached to new routes are rejected. Runnable Arc examples and explicit live-test gates are described in [examples/README.md](examples/README.md). No live test runs by default. + + + +## Compliance + +A USDCx unshield and a private xReserve burn prove in zero knowledge that the +signer's private address is not on the USDCx freezelist. The SDK builds that +Merkle exclusion proof locally and passes it as a private input of the +transaction. The program checks the Merkle paths inside the transaction proof, +and its finalize compares the reconstructed root with the root stored on +chain. The network learns that the signer is absent from the list; it does not +learn the signer's address or the paths. The recipient of an unshield stays +public, as before. + +Proof generation is on by default. Leave `compliance_proofs` unset and the +SDK does the work; set it only to replace the proof the SDK would build. + +### Functions that take a compliance proof + +Each call takes one `[MerkleProof; 2]` bundle: two Merkle paths proving that +the signer is absent from the asset's freezelist. + +| Function | When a proof is needed | Program function | +| --- | --- | --- | +| `bridge.unshield()` | Unshielding an ARC-22 asset such as USDCx | `transfer_private_to_public` | +| `bridge.execute()` | An Aleo USDCx xReserve burn with `mode="private"`, the default | `private_burn` | +| `bridge.xreserve.burn()` | The same private burn as a single call | `private_burn` | + +The subject is always the signer, even when the unshield recipient is another +address. ARC-20 unshielding, `bridge.shield()`, and burns with `mode="public"` +or `mode="public-as-signer"` take no proof. + +### Automatic generation + +Before each call the SDK reads the list's current root +(`freeze_list_root[1u8]` on `usdcx_freezelist.aleo`). When the root changed +since the last call it downloads the published tree from +`GET /programs/{id}/compliance/freeze-list`, checks that the tree ends in that +root, builds the signer's witness, and checks that the witness reconstructs +the root. Trees and witnesses stay in memory per client, list, and root, so +while the root is unchanged nothing beyond the root is read. An empty list +uses the empty-tree witness without a download. A frozen signer, a missing +root, or a published tree that does not match the root raises +`ConfigurationError` before anything is proved or a fee is paid; the message +says which. The cache holds at most 16 lists and 256 addresses per list and +does not survive a restart. + +The list is found through the token program's imports: +`usdcx_stablecoin.aleo` imports `usdcx_freezelist.aleo`, and the testnet +programs carry a `test_` prefix. `bridge.freezelist.exclusion_proof(address, +program)` returns the bundle for any address, and +`bridge.freezelist.clear_cache()` drops the cached trees. + +### Optional proof inputs + +`compliance_proofs` takes a `ComplianceProofs` value: one bundle, or `None`. + +| Value | Meaning | +| --- | --- | +| `None` (the default) | The SDK builds the proof. | +| An encoded `[MerkleProof; 2]` string | Used as given. | +| `MerkleProofPair(...)` or `(MerkleProofInput, MerkleProofInput)` | The same proof as structured values. `MerkleProofInput` takes siblings as ints or `"...field"` literals. | +| A one-element tuple of any of the above | The same, in the tuple form the Shield Swap SDK uses. | + +`merkle_proof=` is the earlier name for the string form and still works. A +supplied proof bypasses the root read and the witness check, so its freshness +is the caller's responsibility. + +```python +from aleo_bridge import MerkleProofInput, MerkleProofPair + +receipt = bridge.unshield("aleo/usdcx", amount="2.5").delegate() # proof built by the SDK + +bundle = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1), + MerkleProofInput(siblings=("0field",) * 16, leaf_index="1u32")) +receipt = bridge.unshield("aleo/usdcx", amount="2.5", compliance_proofs=bundle).delegate() +``` diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index b56daa16..a9f78dd0 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -176,58 +176,6 @@ Two conveniences trade secret material for service: `aleo.records.register` shares the account's **view key** with the scanning service, which can then see everything the account owns. -## Compliance proofs - -Shield Swap checks two kinds of freeze list. Its own -(`shield_swap_freezelist.aleo`) screens the signer, a position's recipient, -and its withdrawal address when a position is minted, collected, or -rebalanced, and the signer when a swap output is claimed. Each ARC-22 -stablecoin traded through a wrapper (USDCx, USAD) screens the signer when its -records are wrapped and the receiving address when they are unwrapped. Every -check is one `[MerkleProof; 2]` argument — a *bundle* — and a routed call -takes up to six. - -By default the SDK builds every bundle a call needs. It works out which -entrypoint the call resolves to and which address each bundle screens, reads -each list's current root, builds the bundles (the empty-tree pair while a -list is empty, a verified witness from the published tree otherwise), and -reuses them until the list's root changes. Nothing changes in how the -methods are called: - -```python -dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi, - amount0_desired=a0, amount1_desired=a1) # proofs built by the SDK -dex.claim_swap_output(handle) # likewise -``` - -An address that is on a list gets `FrozenAddressError` before any fee is -paid; an unreadable root or a published tree that does not match it raises -`FreezeListError` naming the fix. - -Pass `compliance_proofs=` to supply the bundles yourself, as a tuple in the -entrypoint's input order. Each element is the encoded literal, a -`MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple. The count must -match the route the call resolves to, and the per-method aliases spell out -the possibilities: - -| Method | Type | Bundles, in order | -|---|---|---| -| `swap` | `SwapProofs` | 0–1: the signer's, when the input is wrapped. | -| `claim_swap_output` | `ClaimProofs` | 1–3: AMM signer, then a receiver bundle per wrapped leg paid (output, then refund). | -| `mint` | `MintProofs` | 3–5: a sender bundle per wrapped side (token0 first), then AMM signer, recipient, withdrawal. | -| `increase_liquidity` | `IncreaseProofs` | 0–2: a sender bundle per wrapped side. | -| `collect` | `CollectProofs` | 2–4: AMM owner, withdrawal, then a receiver bundle per wrapped side. | -| `rebalance_position` | `RebalanceProofs` | 2–6: a sender bundle per funded wrapped side, a receiver bundle per wrapped side, then AMM owner, withdrawal. | - -A wrong count raises `ValueError` listing the slots the route expects. -`wrapper_proofs=` — the earlier single-literal argument — still fills every -wrapped-side slot while the AMM slots are built by the SDK; it cannot be -combined with `compliance_proofs=`. - -`dex.freezelist` is the resolver (`aleo.FreezeListProofs`): -`dex.freezelist.exclusion_proof(address, program)` returns one bundle for any -address and list, and `dex.freezelist.clear()` forgets the cached trees. - ## Async `AsyncShieldSwap` / `AsyncApiClient` mirror the sync **read, swap and claim** @@ -286,3 +234,112 @@ skips certificate verification); `ALEO_DEVNODE_UNPROVEN=1` extends that to executions and is the fast path (~5 minutes). Without it, executions are fully proven locally: expect SNARK parameter downloads and key synthesis on first use. + + + +## Compliance + +Swaps and liquidity actions prove in zero knowledge that the addresses taking +part are not on a freezelist. Two kinds of list apply. Shield Swap's own list +(`shield_swap_freezelist.aleo`) screens the signer when a swap output is +claimed, and the signer, the position's recipient, and its withdrawal address +when a position is minted, collected, or rebalanced. Each ARC-22 stablecoin +that trades through a wrapper (USDCx, USAD) screens the signer when its +records are wrapped and the receiving address when they are unwrapped, against +the stablecoin's own list. + +The SDK builds these Merkle exclusion proofs locally and passes them as +private inputs of the transaction. The programs check the Merkle paths inside +the transaction proof, and their finalize compares each reconstructed root +with the root stored on chain. The network learns that an address is absent +from a list; it does not learn the address or the paths. + +Proof generation is on by default. Leave `compliance_proofs` unset and the +calls look as they always did: + +```python +dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi, + amount0_desired=a0, amount1_desired=a1) +dex.claim_swap_output(handle) +``` + +### Functions that take compliance proofs + +Each bundle is one `[MerkleProof; 2]` input: two Merkle paths proving that one +address is absent from one list. The counts are input slots on the entrypoint +the call resolves to, including the placeholder slot of a wrapper that ignores +proofs (credits). + +| Method | Type | Bundles | Subjects, in input order | +| --- | --- | --- | --- | +| `swap()` | `SwapProofs` | 0 to 1 | The signer, for a wrapped input token | +| `claim_swap_output()` | `ClaimProofs` | 1 to 3 | Shield Swap signer, then the wrapped output receiver and the wrapped refund receiver when there is a refund | +| `mint()` | `MintProofs` | 3 to 5 | Wrapped token senders, then Shield Swap signer, recipient, and withdrawal address | +| `increase_liquidity()` | `IncreaseProofs` | 0 to 2 | Wrapped token senders | +| `collect()` | `CollectProofs` | 2 to 4 | Shield Swap owner and withdrawal address, then wrapped token receivers | +| `rebalance_position()` | `RebalanceProofs` | 2 to 6 | Funded wrapped token senders, wrapped token receivers, then Shield Swap owner and withdrawal address | + +Shield Swap slots use the AMM list. Wrapped-token slots use the underlying +stablecoin's list, found through that program's imports +(`test_usdcx_stablecoin.aleo` imports `test_usdcx_freezelist.aleo`). Senders +are the signer. Claim receivers are the signer too; collect and rebalance +receivers are the PositionNFT's withdrawal address. Within each group side 0 +comes before side 1, a plain side has no slot, and a rebalance keeps its +receiver slots even when a side has no refund. A multi-hop swap adds no slot +for its intermediate hops. `decrease_liquidity()` and `burn()` take no proofs; +`swap_many()` and `collect_all()` always let the SDK build them. + +### Automatic generation + +Before each call the SDK works out the entrypoint, the slots, and the address +each slot screens, then reads each list's current root +(`freeze_list_root[1u8]`) once, so a mint's three Shield Swap bundles share +one root. When a root changed since the last call it downloads the published +tree from `GET /programs/{id}/compliance/freeze-list`, checks that the tree +ends in that root, builds each witness, and checks that the witness +reconstructs the root. Trees and witnesses stay in memory per client, list, +and root, so while a root is unchanged nothing beyond the root is read. An +empty list uses the empty-tree witness without a download. + +A frozen address raises `FrozenAddressError`, and a missing root or a +published tree that does not match the root raises `FreezeListError`, in each +case before anything is proved or a fee is paid. A wrapped token whose +underlying program the registry cannot name raises `ValueError`; passing +`token0_program=` (or the matching argument) names it. The cache holds at +most 16 lists and 256 addresses per list and does not survive a restart. + +`dex.freezelist` is the resolver, an `aleo.FreezeListProofs`: +`dex.freezelist.exclusion_proof(address, program)` returns one bundle for any +address and list, `dex.freezelist.resolve(contexts)` fills a list of +`ComplianceProofContext` values, and `dex.freezelist.clear()` drops the +cached trees. `dex.amm_freezelist_program` names the AMM list for a +non-standard deployment. + +### Optional proof inputs + +`compliance_proofs` takes a `ComplianceProofs` value: the method's tuple of +bundles in input order, or `None`. Each bundle is an encoded `[MerkleProof; 2]` +string, a `MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple; +`MerkleProofInput` takes siblings as ints or `"...field"` literals. The count +is checked against the route the call resolves to before anything runs, and a +mismatch raises `ValueError` naming the slots the route expects. The same +bundle may fill several slots when the list and the address match. + +`wrapper_proofs=`, the earlier single-string argument, still fills every +wrapped-token slot while the Shield Swap slots are built by the SDK. It cannot +be combined with `compliance_proofs`. A supplied bundle bypasses the root read +and the witness check, so its freshness is the caller's responsibility. + +```python +from aleo_shield_swap import MerkleProofInput, MerkleProofPair + +dex.swap(pool_key=key, token_in_id=usdcx, amount_in=10**6, + compliance_proofs=(sender_bundle,)) # one wrapped input +dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi, + amount0_desired=a0, amount1_desired=a1, + compliance_proofs=(signer_bundle, recipient_bundle, withdrawal_bundle)) # plain pool +pair = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1), + MerkleProofInput(siblings=("0field",) * 16, leaf_index="1u32")) +dex.collect(pool_key=key, amount0_requested=x, amount1_requested=y, + compliance_proofs=(pair, pair, usdcx_receiver_bundle)) # token0 wrapped +``` From 54ff604ebcad1d3f4de6840fe5c5e4491bd1ef48 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 05:08:22 -0400 Subject: [PATCH 09/12] docs: humanizer pass on the Compliance sections --- bridge-sdk/README.md | 6 +++--- shield-swap-sdk/README.md | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 441012fd..650d4ecc 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -943,9 +943,9 @@ signer's private address is not on the USDCx freezelist. The SDK builds that Merkle exclusion proof locally and passes it as a private input of the transaction. The program checks the Merkle paths inside the transaction proof, and its finalize compares the reconstructed root with the root stored on -chain. The network learns that the signer is absent from the list; it does not -learn the signer's address or the paths. The recipient of an unshield stays -public, as before. +chain. The network verifies that the signer is absent from the list without +seeing the signer's address or the paths. The recipient of an unshield stays +public. Proof generation is on by default. Leave `compliance_proofs` unset and the SDK does the work; set it only to replace the proof the SDK would build. diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index a9f78dd0..2bf46806 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -251,11 +251,11 @@ the stablecoin's own list. The SDK builds these Merkle exclusion proofs locally and passes them as private inputs of the transaction. The programs check the Merkle paths inside the transaction proof, and their finalize compares each reconstructed root -with the root stored on chain. The network learns that an address is absent -from a list; it does not learn the address or the paths. +with the root stored on chain. The network verifies that an address is absent +from a list without seeing the address or the paths. -Proof generation is on by default. Leave `compliance_proofs` unset and the -calls look as they always did: +Proof generation is on by default. Leave `compliance_proofs` unset and call +the methods as usual: ```python dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi, From 1e9ae0a6a059e305fc30333138bc74959aead377 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 05:21:46 -0400 Subject: [PATCH 10/12] chore: bump SDKs to 0.7.0 aleo-sdk, aleo-contract-abi-generator, shield-swap-sdk and aleo-bridge-sdk move to 0.7.0 in lockstep (manifests, lockfiles, docs conf, __version__ and the version tests). The shield-swap and bridge aleo-sdk floors rise to >=0.7.0: both now import aleo.compliance, which first ships in this release. --- bridge-sdk/pyproject.toml | 4 ++-- bridge-sdk/python/aleo_bridge/__init__.py | 2 +- bridge-sdk/tests/test_client_lifecycle.py | 2 +- bridge-sdk/tests/test_package.py | 4 ++-- sdk-abi/Cargo.lock | 2 +- sdk-abi/Cargo.toml | 2 +- sdk-abi/pyproject.toml | 2 +- sdk/Cargo.lock | 2 +- sdk/Cargo.toml | 2 +- sdk/docs/source/conf.py | 2 +- sdk/pyproject.toml | 2 +- shield-swap-sdk/pyproject.toml | 4 ++-- shield-swap-sdk/python/aleo_shield_swap/__init__.py | 2 +- shield-swap-sdk/tests/test_package.py | 2 +- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml index a1d27972..deed5020 100644 --- a/bridge-sdk/pyproject.toml +++ b/bridge-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aleo-bridge-sdk" -version = "0.6.2" +version = "0.7.0" description = "Bridge Aleo, Ethereum, Arc, Base, Arbitrum and Solana assets with Hyperlane, Circle xReserve and CCTP" readme = "README.md" requires-python = ">=3.10" @@ -12,7 +12,7 @@ requires-python = ">=3.10" # Solana legs alike, so a checkpoint written on one machine can always be recovered on another. # The Privy and Dynamic server-wallet SDKs (aleo_bridge.privy / aleo_bridge.dynamic) ship the same way; # dynamic-wallet-sdk itself requires Python 3.11, so on 3.10 that one module reports it missing. -dependencies = ["aleo-sdk>=0.5.0", "requests>=2", "pynacl>=1.5", +dependencies = ["aleo-sdk>=0.7.0", "requests>=2", "pynacl>=1.5", "web3>=7,<9", "eth-account>=0.13", "solders>=0.21", "solana>=0.35", "privy-client>=0.7", diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index ba822042..d5141d7e 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -8,7 +8,7 @@ from pathlib import Path -__version__ = "0.6.2" +__version__ = "0.7.0" from .errors import ( # noqa: E402 AmbiguousRouteError, AttestationError, BridgeError, ChainMismatchError, CheckpointInvalidError, diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py index 2ae5a560..45341ace 100644 --- a/bridge-sdk/tests/test_client_lifecycle.py +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -205,7 +205,7 @@ def test_public_exports_pin_pre_existing_set_and_add_lifecycle_names(): assert hasattr(aleo_bridge, name), name assert set(PRE_EXISTING_EXPORTS) <= set(aleo_bridge.__all__) assert set(NEW_EXPORTS) <= set(aleo_bridge.__all__) - assert aleo_bridge.__version__ == "0.6.2" + assert aleo_bridge.__version__ == "0.7.0" assert aleo_bridge.lifecycle is lifecycle assert aleo_bridge.prepare is lifecycle.prepare assert aleo_bridge.bridge_tools is agent.bridge_tools diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py index 700a4e6e..623b64fc 100644 --- a/bridge-sdk/tests/test_package.py +++ b/bridge-sdk/tests/test_package.py @@ -13,7 +13,7 @@ def test_version_is_pinned_in_lockstep(): pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) - assert pyproject["project"]["version"] == "0.6.2" == aleo_bridge.__version__ + assert pyproject["project"]["version"] == "0.7.0" == aleo_bridge.__version__ assert pyproject["project"]["name"] == "aleo-bridge-sdk" deps = pyproject["project"]["dependencies"] for base in ("aleo-sdk", "pynacl", "web3", "eth-account", "solders", "solana", "privy-client", "dynamic-wallet-sdk"): @@ -105,7 +105,7 @@ def test_import_without_optional_extras(monkeypatch): monkeypatch.delitem(sys.modules, name) # reverted at teardown — a fresh reimport below must not # leak new module/class objects into tests that run after this one pkg = importlib.import_module("aleo_bridge") - assert pkg.__version__ == "0.6.2" + assert pkg.__version__ == "0.7.0" assert issubclass(pkg.RouteNotFoundError, pkg.BridgeError) diff --git a/sdk-abi/Cargo.lock b/sdk-abi/Cargo.lock index 91350b73..89b19798 100644 --- a/sdk-abi/Cargo.lock +++ b/sdk-abi/Cargo.lock @@ -19,7 +19,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aleo-abi" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "leo-abi", diff --git a/sdk-abi/Cargo.toml b/sdk-abi/Cargo.toml index b6fd102b..88a66ae4 100644 --- a/sdk-abi/Cargo.toml +++ b/sdk-abi/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "aleo-abi" -version = "0.6.2" +version = "0.7.0" edition = "2024" license = "GPL-3.0-or-later" description = "Python bindings for ABI generation from Aleo bytecode (via Leo's leo-abi crate)" diff --git a/sdk-abi/pyproject.toml b/sdk-abi/pyproject.toml index 353c6e45..db444cda 100644 --- a/sdk-abi/pyproject.toml +++ b/sdk-abi/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "aleo-contract-abi-generator" -version = "0.6.2" +version = "0.7.0" description = "Python bindings for ABI generation from Aleo bytecode" readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock index 300dfc21..71584690 100644 --- a/sdk/Cargo.lock +++ b/sdk/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "aleo" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "hex", diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index d310e515..d806cf57 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "aleo" authors = ["Konstantin Pandl", "Mike Turner", "Roman Proskuryakov"] -version = "0.6.2" +version = "0.7.0" description = "A Python sdk for zero-knowledge cryptography based on Aleo" edition = "2021" license = "GPL-3.0-or-later" diff --git a/sdk/docs/source/conf.py b/sdk/docs/source/conf.py index 767aa119..1c353ccc 100644 --- a/sdk/docs/source/conf.py +++ b/sdk/docs/source/conf.py @@ -11,7 +11,7 @@ copyright = '2019-2026, Provable Inc.' author = 'kpp' -version = '0.6.2' +version = '0.7.0' # -- General configuration diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 5bd1dbbb..7006747e 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aleo-sdk" description = "Python SDK for building zero-knowledge apps and DeFi on the Aleo network" -version = "0.6.2" +version = "0.7.0" readme = "Readme.md" license = {file = "LICENSE.md"} authors = [ diff --git a/shield-swap-sdk/pyproject.toml b/shield-swap-sdk/pyproject.toml index 56c7885b..d617b8a5 100644 --- a/shield-swap-sdk/pyproject.toml +++ b/shield-swap-sdk/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "shield-swap-sdk" -version = "0.6.2" +version = "0.7.0" description = "Python SDK for the shield swap AMM Dex on Aleo" readme = "README.md" requires-python = ">=3.10" -dependencies = ["aleo-sdk>=0.5.1", "requests>=2"] # 0.5.1: first imported account becomes the default signer +dependencies = ["aleo-sdk>=0.7.0", "requests>=2"] # 0.7.0: aleo.compliance (freeze-list proofs) [project.optional-dependencies] async = ["httpx>=0.27"] diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 37591970..53532d18 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -93,7 +93,7 @@ def agent_guide() -> str: return files(__name__).joinpath("AGENTS.md").read_text() -__version__ = "0.6.2" +__version__ = "0.7.0" __all__ = [ "ShieldSwap", "AsyncShieldSwap", "ApiClient", "AsyncApiClient", diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 92da3813..2b6174db 100644 --- a/shield-swap-sdk/tests/test_package.py +++ b/shield-swap-sdk/tests/test_package.py @@ -2,7 +2,7 @@ def test_version(): - assert aleo_shield_swap.__version__ == "0.6.2" + assert aleo_shield_swap.__version__ == "0.7.0" def test_lifecycle_exports(): From 80eee4db0e33feea21f8746f7657f20dded000b3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 05:26:18 -0400 Subject: [PATCH 11/12] feat: fail at import with the aleo-sdk upgrade when aleo.compliance is missing aleo_bridge and aleo_shield_swap check for aleo.compliance on import and raise an ImportError that names the installed aleo-sdk version and the pip command, instead of a bare ModuleNotFoundError from the first private call. --- bridge-sdk/python/aleo_bridge/__init__.py | 24 +++++++++++++++++ bridge-sdk/tests/test_package.py | 8 ++++++ .../python/aleo_shield_swap/__init__.py | 27 ++++++++++++++++++- shield-swap-sdk/tests/test_package.py | 12 +++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index d5141d7e..175cefea 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -9,6 +9,30 @@ from pathlib import Path __version__ = "0.7.0" +_ALEO_SDK_MINIMUM = "0.7.0" + + +def _require_aleo_sdk() -> None: + """Fail at import, naming the fix, when the installed aleo-sdk predates ``aleo.compliance``. + + Freeze-list proofs come from ``aleo.compliance``, which first shipped in aleo-sdk 0.7.0. An + older install would otherwise fail deep inside the first private USDCx call with a bare + ``ModuleNotFoundError``. + """ + try: + import aleo.compliance # noqa: F401 + except ImportError as exc: + from importlib.metadata import PackageNotFoundError, version + try: + found = version("aleo-sdk") + except PackageNotFoundError: + found = "not installed" + raise ImportError( + f"aleo-bridge-sdk {__version__} needs aleo-sdk>={_ALEO_SDK_MINIMUM} (found {found}); " + f'run: pip install -U "aleo-sdk>={_ALEO_SDK_MINIMUM}"') from exc + + +_require_aleo_sdk() from .errors import ( # noqa: E402 AmbiguousRouteError, AttestationError, BridgeError, ChainMismatchError, CheckpointInvalidError, diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py index 623b64fc..cbc48569 100644 --- a/bridge-sdk/tests/test_package.py +++ b/bridge-sdk/tests/test_package.py @@ -132,3 +132,11 @@ def test_polling_timeout_carries_status(): assert err.status == "DELIVERY_PENDING" and err.progress is None with pytest.raises(PollingTimeoutError): raise err + + +def test_import_guard_names_the_aleo_sdk_upgrade(monkeypatch): + import aleo_bridge + aleo_bridge._require_aleo_sdk() # the installed sdk has aleo.compliance + monkeypatch.setitem(sys.modules, "aleo.compliance", None) # simulate an aleo-sdk without it + with pytest.raises(ImportError, match=r'needs aleo-sdk>=0\.7\.0 \(found .*\); run: pip install -U "aleo-sdk>=0\.7\.0"'): + aleo_bridge._require_aleo_sdk() diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 53532d18..3492d059 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -14,7 +14,32 @@ amount_in=10**9).delegate() out = dex.claim_swap_output(handle).delegate() """ -from .client import ShieldSwap as ShieldSwap +_ALEO_SDK_MINIMUM = "0.7.0" + + +def _require_aleo_sdk() -> None: + """Fail at import, naming the fix, when the installed aleo-sdk predates ``aleo.compliance``. + + Freeze-list proofs come from ``aleo.compliance``, which first shipped in aleo-sdk 0.7.0. An + older install would otherwise fail on the first import of the client with a bare + ``ModuleNotFoundError``. + """ + try: + import aleo.compliance # noqa: F401 + except ImportError as exc: + from importlib.metadata import PackageNotFoundError, version + try: + found = version("aleo-sdk") + except PackageNotFoundError: + found = "not installed" + raise ImportError( + f"shield-swap-sdk needs aleo-sdk>={_ALEO_SDK_MINIMUM} (found {found}); " + f'run: pip install -U "aleo-sdk>={_ALEO_SDK_MINIMUM}"') from exc + + +_require_aleo_sdk() + +from .client import ShieldSwap as ShieldSwap # noqa: E402 from .async_client import AsyncShieldSwap as AsyncShieldSwap from .api import ApiClient as ApiClient, AsyncApiClient as AsyncApiClient from .api import ConfirmAirdropResult as ConfirmAirdropResult diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 2b6174db..8d8452a5 100644 --- a/shield-swap-sdk/tests/test_package.py +++ b/shield-swap-sdk/tests/test_package.py @@ -24,3 +24,15 @@ def test_agent_guide_ships_in_the_package(): from importlib.resources import files skill = files("aleo_shield_swap").joinpath("skills/SKILL.md").read_text() assert "python -m aleo_shield_swap" in skill + + +def test_import_guard_names_the_aleo_sdk_upgrade(monkeypatch): + import sys + + import pytest + + import aleo_shield_swap + aleo_shield_swap._require_aleo_sdk() # the installed sdk has aleo.compliance + monkeypatch.setitem(sys.modules, "aleo.compliance", None) # simulate an aleo-sdk without it + with pytest.raises(ImportError, match=r'needs aleo-sdk>=0\.7\.0 \(found .*\); run: pip install -U "aleo-sdk>=0\.7\.0"'): + aleo_shield_swap._require_aleo_sdk() From a5b5b616e60adf0f07dbde04820984c82e299677 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 22 Oct 2026 05:43:02 -0400 Subject: [PATCH 12/12] ci: keep building when sccache cannot reach the Actions cache on native runners With maturin-action's sccache input, sccache starts on the first rustc call and a transient failure to reach the GitHub cache service (a DNS error on a macOS runner in run 39753109410) fails the whole build before compiling. SCCACHE_IGNORE_SERVER_IO_ERROR does not cover server startup. Native runners now start sccache themselves and build without a compiler cache when it does not come up; the manylinux containers keep the action input. --- .github/workflows/sdk-wheels.yml | 64 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index 0fbb2a91..3cff4cbf 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -63,6 +63,31 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + # sccache's GitHub Actions cache is a speed-up, not a requirement. maturin-action's + # `sccache: true` makes it one: sccache starts on the first rustc call and, when the + # runner cannot reach the cache service at that moment (DNS or network blips, seen on + # the macOS runners), the whole build fails before compiling anything. Native builds + # therefore start sccache themselves and drop the compiler cache if it does not come + # up; only the manylinux containers keep the action input. sccache's GHA backend + # reads the two ACTIONS_* values, which shell steps do not get without this export. + - name: Expose the Actions cache credentials to sccache + if: runner.os != 'Linux' + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Start sccache (best effort) + if: runner.os != 'Linux' + shell: bash + run: | + pip install "sccache>=0.10.0" + export SCCACHE_GHA_ENABLED=true SCCACHE_IDLE_TIMEOUT=0 + if sccache --start-server; then + printf 'SCCACHE_GHA_ENABLED=true\nSCCACHE_IDLE_TIMEOUT=0\nRUSTC_WRAPPER=sccache\n' >> "$GITHUB_ENV" + else + echo "::warning::sccache could not reach the GitHub Actions cache; building without a compiler cache" + fi # Build the mainnet extension module wheel. - name: Build mainnet wheel uses: PyO3/maturin-action@v1 @@ -73,7 +98,7 @@ jobs: working-directory: ./sdk target: ${{ matrix.platform.target }} args: --release --out dist --features mainnet - sccache: 'true' + sccache: ${{ runner.os == 'Linux' }} manylinux: ${{ startsWith(matrix.platform.name, 'linux') && '2_28' || 'auto' }} # Install build tools in the manylinux container. # aws-lc-sys needs cmake and a C toolchain. @@ -97,7 +122,7 @@ jobs: working-directory: ./sdk target: ${{ matrix.platform.target }} args: --release --out dist-testnet --no-default-features --features testnet - sccache: 'true' + sccache: ${{ runner.os == 'Linux' }} manylinux: ${{ startsWith(matrix.platform.name, 'linux') && '2_28' || 'auto' }} before-script-linux: | if command -v dnf &> /dev/null; then @@ -141,6 +166,10 @@ jobs: assert testnet.Network.id() == 1 print('two-network wheel ok:', mpk.address, tpk.address) " + - name: sccache stats + if: runner.os != 'Linux' && env.RUSTC_WRAPPER == 'sccache' + shell: bash + run: sccache --show-stats - name: Upload wheel uses: actions/upload-artifact@v4 with: @@ -175,13 +204,38 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + # sccache's GitHub Actions cache is a speed-up, not a requirement. maturin-action's + # `sccache: true` makes it one: sccache starts on the first rustc call and, when the + # runner cannot reach the cache service at that moment (DNS or network blips, seen on + # the macOS runners), the whole build fails before compiling anything. Native builds + # therefore start sccache themselves and drop the compiler cache if it does not come + # up; only the manylinux containers keep the action input. sccache's GHA backend + # reads the two ACTIONS_* values, which shell steps do not get without this export. + - name: Expose the Actions cache credentials to sccache + if: runner.os != 'Linux' + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + - name: Start sccache (best effort) + if: runner.os != 'Linux' + shell: bash + run: | + pip install "sccache>=0.10.0" + export SCCACHE_GHA_ENABLED=true SCCACHE_IDLE_TIMEOUT=0 + if sccache --start-server; then + printf 'SCCACHE_GHA_ENABLED=true\nSCCACHE_IDLE_TIMEOUT=0\nRUSTC_WRAPPER=sccache\n' >> "$GITHUB_ENV" + else + echo "::warning::sccache could not reach the GitHub Actions cache; building without a compiler cache" + fi - name: Build wheel uses: PyO3/maturin-action@v1 with: working-directory: ./sdk-abi target: ${{ matrix.platform.target }} args: --release --out dist - sccache: 'true' + sccache: ${{ runner.os == 'Linux' }} manylinux: ${{ startsWith(matrix.platform.name, 'linux') && '2_28' || 'auto' }} before-script-linux: | if command -v dnf &> /dev/null; then @@ -194,6 +248,10 @@ jobs: run: | pip install sdk-abi/dist/*.whl python -c "import aleo_abi; print(aleo_abi.generate_abi.__name__)" + - name: sccache stats + if: runner.os != 'Linux' && env.RUSTC_WRAPPER == 'sccache' + shell: bash + run: sccache --show-stats - name: Upload wheel uses: actions/upload-artifact@v4 with: