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: diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index df96b7b2..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, 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``. @@ -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: '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). ``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: 'ComplianceProofs' = 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..650d4ecc 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -933,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 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. + +### 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/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/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..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, 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``. @@ -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: '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). ``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: 'ComplianceProofs' = 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..175cefea 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -8,7 +8,31 @@ from pathlib import Path -__version__ = "0.6.2" +__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, @@ -29,7 +53,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, 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,6 +75,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", + "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/_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/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 54f74fdf..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 +from .freezelist import ComplianceProofs, FreezeList 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: ComplianceProofs = 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: 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``. @@ -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..080ca465 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -1,125 +1,91 @@ -"""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_TOKEN_FREEZELISTS, + PROOF_SIBLINGS, + ZERO_ADDRESS, + FreezeListError, + FreezeListProofs, + FrozenAddressError, + MerkleProofInput, + MerkleProofPair, + ComplianceProof, + address_to_field_int, + build_tree, + format_merkle_proof, + generate_leaves, + hash_two, + leaf_indices, + sibling_path, +) +from aleo.compliance import literal_compliance_proof as _literal_compliance_proof + 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) + "]" +#: 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, 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 _literal_compliance_proof(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 +99,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 +112,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 +124,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 +133,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", "MerkleProofInput", "MerkleProofPair", "ComplianceProof", "ComplianceProofs", "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", - "leaf_indices", "sibling_path"] + "leaf_indices", "literal_compliance_proof", "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..caf202a7 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 .freezelist import ComplianceProofs + from ._calls import AleoCall from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from .freezelist import literal_compliance_proof 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: ComplianceProofs = 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 = literal_compliance_proof(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..e1237eae 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -11,6 +11,7 @@ from ._calls import AleoCall from ._keccak import keccak256 from .circle import CircleClient +from .freezelist import ComplianceProofs, literal_compliance_proof from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Route @@ -136,22 +137,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: 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). + + ``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: 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: raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + 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=/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 +185,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..5ed5c078 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_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) + 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/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/live/test_compliance_live.py b/bridge-sdk/tests/live/test_compliance_live.py new file mode 100644 index 00000000..2047a53f --- /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, generate_freezelist_proof + +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_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 = 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_compliance_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/live/test_frozen_live.py b/bridge-sdk/tests/live/test_frozen_live.py new file mode 100644 index 00000000..c0c32405 --- /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_compliance_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/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py index 14ffeed0..45341ace 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 @@ -204,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_freezelist.py b/bridge-sdk/tests/test_freezelist.py index 8df793c1..c5fc911f 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.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}): + 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_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_package.py b/bridge-sdk/tests/test_package.py index 700a4e6e..cbc48569 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) @@ -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/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_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" 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-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/sdk/python/aleo/__init__.py b/sdk/python/aleo/__init__.py index df109e5e..c1b0aa02 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, + MerkleProofInput as MerkleProofInput, + MerkleProofPair as MerkleProofPair, + ComplianceProof as ComplianceProof, + ComplianceProofContext as ComplianceProofContext, +) 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..b96eefdb 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_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 + 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..d495806e --- /dev/null +++ b/sdk/python/aleo/compliance.py @@ -0,0 +1,722 @@ +"""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:`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:`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 +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_TOKEN_FREEZELISTS: 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 | 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`. + """ + return format_merkle_proof_pair((MerkleProofInput(tuple(left[0]), left[1]), + MerkleProofInput(tuple(right[0]), right[1]))) + + +# ── Proof values ───────────────────────────────────────────────────────────── + +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. + leaf_index: The leaf's position in the tree, a ``u32``. + """ + + siblings: tuple[int, ...] + leaf_index: int + + 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(self.sibling_literals) + f"], leaf_index: {self.leaf_index}u32 }}" + + @classmethod + def from_string(cls, text: str) -> "MerkleProofInput": + """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: MerkleProofInput + right: MerkleProofInput + + 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(MerkleProofInput.from_string(parts[0]), MerkleProofInput.from_string(parts[1])) + + +#: One compliance proof as a caller may supply it: the ``[MerkleProof; 2]`` +#: literal, a :class:`MerkleProofPair`, or the two :class:`MerkleProofInput` paths. +#: 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: + """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: ComplianceProof) -> 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) 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 (MerkleProofInput, MerkleProofInput) tuple") + + +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. + + Normalises the proof arguments of a call that takes exactly one bundle: + 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. + + 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(ComplianceProof, items)) + if len(items) != 1: + raise FreezeListError(f"This operation requires exactly one compliance proof bundle; received {len(items)}") + return _bundle_literal(cast(ComplianceProof, items[0])) + return _bundle_literal(cast(ComplianceProof, compliance_proofs)) + + +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 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 + 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 = [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: MerkleProofInput, 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 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_TOKEN_FREEZELISTS: + return KNOWN_TOKEN_FREEZELISTS[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 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_TOKEN_FREEZELISTS`. + 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_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 = freezelist_from_imports(program, imports) + if isinstance(found, _Unresolved): + raise _unresolved_error(program) + return found + + +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_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 = freezelist_from_imports(program, imports) + if isinstance(found, _Unresolved): + raise _unresolved_error(program) + return found + + +# ── Resolution with a per-client cache ─────────────────────────────────────── + +@dataclass(frozen=True) +class ComplianceProofContext: + """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 = 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 + 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 = freezelist_program(self._aleo, program) + if freezelist is None: + return EMPTY_MERKLE_PROOF_PAIR + return self.resolve([ComplianceProofContext(freezelist, address)])[0] + + 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 + 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_compliance_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_freezelist_program(self._aleo, program) + if freezelist is None: + return EMPTY_MERKLE_PROOF_PAIR + return (await self.resolve([ComplianceProofContext(freezelist, address)]))[0] + + 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] = [] + 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_compliance_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_TOKEN_FREEZELISTS", "PROOF_SIBLINGS", "ZERO_ADDRESS", + "AsyncFreezeListProofs", "FreezeListError", "FreezeListProofs", "FrozenAddressError", + "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/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index 1b1cf316..d5a6c6df 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_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_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 1216c94d..55ac9167 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_compliance_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_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 278a1a6c..919a89a3 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_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 + 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..f82245f7 --- /dev/null +++ b/sdk/python/tests/test_compliance.py @@ -0,0 +1,447 @@ +"""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, MerkleProofInput, MerkleProofPair, ComplianceProofContext, literal_compliance_proof, generate_freezelist_proof, + freezelist_program, literal_compliance_proof, +) + +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 = MerkleProofInput(tuple([A_FIELD, 0] + [0] * 14), 1) + assert str(one) == "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" + 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( + MerkleProofInput((0,) * 16, 1), MerkleProofInput((0,) * 16, 1)) + with pytest.raises(FreezeListError, match="16 siblings"): + MerkleProofInput((1, 2), 0) + with pytest.raises(FreezeListError, match="Not a MerkleProof"): + MerkleProofInput.from_string("[nope]") + with pytest.raises(FreezeListError, match="two proofs"): + 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 + 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"): + literal_compliance_proof("not a literal") + with pytest.raises(FreezeListError, match="MerkleProofPair"): + literal_compliance_proof((one,)) # type: ignore[arg-type] + + +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"): + literal_compliance_proof(EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR) + with pytest.raises(FreezeListError, match="exactly one"): + literal_compliance_proof((EMPTY_MERKLE_PROOF_PAIR, EMPTY_MERKLE_PROOF_PAIR)) + with pytest.raises(FreezeListError, match="merkle_proof= takes the encoded"): + literal_compliance_proof(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 = 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 + 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(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(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): + generate_freezelist_proof(tree, ZERO, root, NET) + + +def test_exclusion_proof_from_tree_empty_list_is_the_fixed_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"): + generate_freezelist_proof(tree, A, root, NET) + with pytest.raises(FreezeListError, match="does not match the on-chain root"): + generate_freezelist_proof(tree, RECIPIENT, root + 1, NET) + with pytest.raises(FreezeListError, match="complete binary tree"): + generate_freezelist_proof(tree[:-1], RECIPIENT, root, NET) + unsorted = [0, A_FIELD, B_FIELD, C_FIELD, *tree[4:]] + with pytest.raises(FreezeListError, match="sorted"): + 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"): + generate_freezelist_proof(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_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): + 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_compliance_freeze_list(self, program_id: str) -> list[int] | None: # type: ignore[override] + return super().get_compliance_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 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 freezelist_program(fake, "shield_swap_arc20_wrapped_usdcx.aleo") == "test_usdcx_freezelist.aleo" + with pytest.raises(FreezeListError, match="names no freeze-list program"): + freezelist_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"): + freezelist_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 == 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 + 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) == generate_freezelist_proof(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([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] == 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")] + + +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") == 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([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) + 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..7f08fea5 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_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 140c4202..a011f9d2 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_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 765c3bc2..922576ef 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_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" + + +@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_compliance_freeze_list("credits.aleo") is None + assert c.get_compliance_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_compliance_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_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 6d4f8171..c4090811 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_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_compliance_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..2bf46806 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -234,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 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 call +the methods as usual: + +```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 +``` 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/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..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 @@ -22,6 +47,19 @@ RebalancePlan as RebalancePlan, RebalanceResult as RebalanceResult, ) +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, + RebalanceProofs as RebalanceProofs, + SHIELD_SWAP_FREEZELIST as SHIELD_SWAP_FREEZELIST, + SwapProofs as SwapProofs, +) from .types import ( OwnedPosition as OwnedPosition, OwnedPositionState as OwnedPositionState, @@ -80,7 +118,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/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..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,6 +16,7 @@ from typing import Any, Callable, Generic, Optional, TypeVar from aleo import AleoNetworkError, ProgramNotFound +from aleo.compliance import AsyncFreezeListProofs, ComplianceProofContext, async_freezelist_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_freezelist_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 = [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 {} + 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..18ed276b 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, ComplianceProofContext, freezelist_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 freezelist_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 = [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 {} + 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..a0078f1a --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/compliance.py @@ -0,0 +1,242 @@ +"""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.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 +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, + MerkleProofInput, + MerkleProofPair, + ComplianceProof, + ComplianceProofContext, + literal_compliance_proof, +) + +SHIELD_SWAP_FREEZELIST = "shield_swap_freezelist.aleo" + +_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). +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]] +#: 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"] + + +@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[ComplianceProof], 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 [_literal(b) for b in supplied] + except FreezeListError as exc: + raise ValueError(f"{action}: {exc}") from exc + + +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[ComplianceProof]) -> bool: + 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[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(ComplianceProofContext(amm_list, slot.subject)) + else: + assert slot.token_id is not None + 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 = _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", + "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", +] diff --git a/shield-swap-sdk/tests/conftest.py b/shield-swap-sdk/tests/conftest.py index 4caab039..909a8b83 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_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) + 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..76693c62 --- /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, ComplianceProofContext, generate_freezelist_proof +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_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 = 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_compliance_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([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") + 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/integration/test_frozen_live.py b/shield-swap-sdk/tests/integration/test_frozen_live.py new file mode 100644 index 00000000..28b624ba --- /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_compliance_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() diff --git a/shield-swap-sdk/tests/test_async_client.py b/shield-swap-sdk/tests/test_async_client.py index 618fab58..3fcaf971 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_compliance_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..8cf14ea3 --- /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, 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 +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 = MerkleProofInput((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 (MerkleProofInput, MerkleProofInput) 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.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 + 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 diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 92da3813..8d8452a5 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(): @@ -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()