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()