diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index 834b3d7..1b70bff 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -6,6 +6,7 @@ on: - 'sdk/**' - 'sdk-abi/**' - 'shield-swap-sdk/**' + - 'bridge-sdk/**' - '.github/workflows/sdk-wheels.yml' - '.github/workflows/sdk.yml' branches: @@ -18,6 +19,7 @@ on: - 'sdk/**' - 'sdk-abi/**' - 'shield-swap-sdk/**' + - 'bridge-sdk/**' - '.github/workflows/sdk-wheels.yml' - '.github/workflows/sdk.yml' workflow_dispatch: @@ -271,17 +273,62 @@ jobs: name: shield-swap-wheels-universal path: shield-swap-sdk/dist + # aleo-bridge-sdk is pure Python (hatchling): one universal wheel + sdist. + # Tested against the built aleo-sdk wheel; installed WITH every extra for the + # suite and WITHOUT extras for the import smoke — an Aleo-only install must + # import (extras raise MissingExtraError at the point of use, never at import). + # Only the hermetic suite runs here (`-m "not live"`): no key, no live RPC. + build-bridge: + name: bridge-build + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + name: wheels-linux-x86_64 + path: sdk-dist + - name: Build wheel + sdist + run: | + pip install build + python -m build bridge-sdk --outdir bridge-sdk/dist + - name: Test against the built aleo-sdk wheel (all extras, hermetic only) + run: | + pip install sdk-dist/aleo_sdk-*.whl + pip install "$(ls bridge-sdk/dist/*.whl)[evm,solana,mcp]" pytest pytest-asyncio + cd bridge-sdk && python -m pytest -q -m "not live" + - name: AGENTS.md up to date + run: python bridge-sdk/codegen/gen_context.py --check + - name: Smoke test wheel with extras + run: | + cd "$RUNNER_TEMP" + python -c "import aleo_bridge, aleo_bridge.eth, aleo_bridge.sol, aleo_bridge.mcp; print('aleo-bridge-sdk', aleo_bridge.__version__)" + python -m aleo_bridge | head -1 + - name: Smoke test wheel WITHOUT extras + run: | + pip uninstall -y aleo-bridge-sdk web3 eth-account solders solana mcp + pip install "$(ls bridge-sdk/dist/*.whl)" + python -c "import aleo_bridge; from aleo_bridge import Bridge, Ethereum, Solana, DEFAULT_REGISTRY; print(len(DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None)), 'routes')" + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: bridge-wheels-universal + path: bridge-sdk/dist + # Publishing uses PyPI trusted publishing (OIDC) — no token secret. Each # package has its own job because a pending publisher must be unique per # (repo, workflow, environment): the job's environment name must exactly - # match the publisher registered on PyPI for that project. All three gate - # on every build job, and they release strictly in dependency order: - # abi -> sdk -> shield-swap (shield-swap-sdk requires aleo-sdk on PyPI). + # match the publisher registered on PyPI for that project. All gate on + # every build job, and they release strictly in dependency order: + # abi -> sdk -> shield-swap / bridge (both require aleo-sdk on PyPI). release-abi: name: Release aleo-contract-abi-generator runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge] environment: pypi-abi permissions: id-token: write @@ -301,7 +348,7 @@ jobs: name: Release aleo-sdk runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, release-abi] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-abi] environment: pypi permissions: id-token: write @@ -321,7 +368,7 @@ jobs: name: Release shield-swap-sdk runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, release-sdk] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-sdk] environment: pypi-shield-swap permissions: id-token: write @@ -336,3 +383,23 @@ jobs: with: packages-dir: dist skip-existing: true + + release-bridge: + name: Release aleo-bridge-sdk + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-sdk] + environment: pypi-bridge + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: 'bridge-wheels-*' + merge-multiple: true + path: dist + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true diff --git a/bridge-sdk/.gitignore b/bridge-sdk/.gitignore new file mode 100644 index 0000000..5ce617b --- /dev/null +++ b/bridge-sdk/.gitignore @@ -0,0 +1,5 @@ +.venv/ +dist/ +__pycache__/ +.pytest_cache/ +*.egg-info/ diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md new file mode 100644 index 0000000..93291cb --- /dev/null +++ b/bridge-sdk/AGENTS.md @@ -0,0 +1,331 @@ +# aleo-bridge — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client that moves assets between Aleo, Ethereum and Solana +over the reviewed Hyperlane warp routes and Circle xReserve deployments +(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`). +MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as +tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas. +Registry version `2026-08-31.solana-deposits.1`. + +## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked) + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +``` + +### `from_env(**overrides: 'Any') -> "'Bridge'"` + +Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints. + +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None) -> "'Bridge'"` + +The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when +creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. + +### `status(self) -> 'BridgeStatus'` + +Read-only re-orientation: addresses and public balances of every registry asset per configured chain. +Plan 4 fills ``pending`` from the checkpoint store. + +### `quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, protocol: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` + +Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + +``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` +tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of +``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is +the destination-chain address. ``mint_mode`` (xReserve into Aleo only): +``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` +— you finish it yourself with ``complete`` and must keep ``secret_nonce``. +Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / +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)` + +Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + +Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to +``on_checkpoint`` (and the bound store) at every boundary — including +AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is +resumable without proving twice. ``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 +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. + +### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` + +Poll until the transfer finishes or needs you: stops at ``progress.next`` +in resume / complete / done / failed, or at any status in ``until``. + +A ``PollingTimeoutError`` is NOT a failure — the transfer is still in +flight; call ``wait`` again or ``recover`` later. ``on_update`` receives +each changed ``Progress``. A transient error (flaky RPC/HTTP transport) +is retried up to ``max_consecutive_errors`` times, calling ``on_error`` +on each tolerated retry; a non-transient error propagates immediately. + +### `recover(self, checkpoint)` + +Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + +Re-resolves the route from the live registry and reads chain state once; +``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, +``done`` or ``failed``. + +### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` + +Finish an interrupted source submission (``progress.next == "resume"``). + +Rebroadcasts the identical proved Aleo transaction (a duplicate response is +success) or, on EVM, re-scans history and only then authorizes the single +missing deposit/dispatch. Never repeats a confirmed step. + +### `complete(self, progress, *, secret_nonce: 'str', on_checkpoint=None, proving: 'str' = 'delegate')` + +Submit the private USDCx mint (``progress.next == "complete"``). + +Requires the same ``secret_nonce`` given to ``execute``; the SDK never +stored it. Submits exactly one ``private_mint`` and returns +``DESTINATION_CONFIRMING`` progress to ``wait`` on. + +### `pending(self) -> 'list'` + +The in-flight transfers of this profile — every checkpoint in the bound store, +reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one +unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` +with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` +on any entry to refresh it against live chain state. + +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. + + +## Tier 2 — the protocol modules (building your own flows) + +Every Aleo write returns an `AleoCall`: nothing touches the network until +`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not +broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local +proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana +writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`. +The lifecycle verbs above compose these; use them directly only when you +need a single leg. Confirm-gated writes and the never-resend rule above +apply here too — these are the same broadcasts, just one leg at a time. + +### `hyperlane.transfer_remote(self, asset: 'Any', recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, as_signer: 'bool' = False, gas_payment_microcredits: 'int | None' = None) -> 'AleoCall[DispatchReceipt]'` + +Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the +lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again. + +### `hyperlane.quote_gas_payment(self, asset: 'Any') -> 'GasQuote'` + +Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. + +### `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) -> 'AleoCall[BurnReceipt]'` + +Burn USDCx for USDC on Ethereum. ``private`` (default) 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. + +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` + +Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). + +### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` + +One Circle request for *message_hash*; ``None`` while pending (404). + +### `shield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `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]'` + + + +### `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. + +### `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]'` + +Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + +Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the +quoted token amount only when the allowance is short (USDT: a non-zero allowance is +reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes +send the fee only. Each hash is checkpointed before polling; a timeout returns a +pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. + +### `eth.deposit_usdc(self, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mint_mode: 'str | None' = None, secret_nonce: 'str' = '0scalar', plan: 'Plan | None' = None) -> 'EvmCall[DepositReceipt]'` + +Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + +``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private +record), or ``private`` (deposit addressed to the shielded wrapper program; you must later +run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, +which the SDK never stores). Approves exactly the amount only when the allowance is +short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` +carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to +``plan.mint_mode`` when a plan is given, else ``"public"``. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. ``secret_nonce`` is never +part of a plan, so a private deposit must still pass the same one it was quoted with. + +### `eth.quote_transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, route: 'Route | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'EvmHyperlaneQuote'` + +Quote an Ethereum → Aleo Hyperlane transfer without signing. + +Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so +``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): +``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 +allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + +``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and +amount, and is validated against the live registry. It is mutually exclusive with +``asset=``/``route=``/``sender=``. + +### `sol.transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'SolCall[DispatchReceipt]'` + +Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + +Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, +``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + +``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for +the connected wallet; its registry version and route id are re-checked against the live registry +when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a +``ValueError``. Without a plan, ``recipient`` is required. + +### `sol.quote_transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'SolanaHyperlaneQuote'` + +Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind +``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required +for the fee estimate. + +``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry +version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ +``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so +re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + +### Routes in the pinned registry + +| Route id | Protocol | Environment | Availability | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->ethereum/usdc` | xreserve | mainnet | active | +| `xreserve:sepolia/usdc->aleo-testnet/usdcx` | xreserve | testnet | active | +| `xreserve:aleo-testnet/usdcx->sepolia/usdc` | xreserve | testnet | active | +| `hyperlane:ethereum/eth->aleo/eth` | hyperlane | mainnet | active | +| `hyperlane:aleo/eth->ethereum/eth` | hyperlane | mainnet | active | +| `hyperlane:ethereum/wbtc->aleo/wbtc` | hyperlane | mainnet | active | +| `hyperlane:aleo/wbtc->ethereum/wbtc` | hyperlane | mainnet | active | +| `hyperlane:ethereum/usdt->aleo/usdt` | hyperlane | mainnet | active | +| `hyperlane:aleo/usdt->ethereum/usdt` | hyperlane | mainnet | active | +| `hyperlane:solana/sol->aleo/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/sol->solana/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/aleo->ethereum/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->solana/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:solana/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->base/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:base/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->hyperevm/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:hyperevm/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/usad->aleo/usad` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/usad->ethereum/usad` | hyperlane | mainnet | metadata-required | + +`metadata-required` routes are listed but refused by `quote`/`execute` +until their deployments are reviewed upstream. + diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md new file mode 100644 index 0000000..0264ae9 --- /dev/null +++ b/bridge-sdk/README.md @@ -0,0 +1,463 @@ +# aleo-bridge-sdk + +Move assets between **Aleo**, **Ethereum** and **Solana** from Python, over the +reviewed Hyperlane warp routes (ETH, WBTC, USDT, SOL) and Circle xReserve +(USDC ↔ USDCx). Web3.py idioms on top of the `aleo` facade: reads return +values, writes return prepared calls, the lifecycle is `quote → execute → +wait`, and `recover` / `resume` / `complete` pick up wherever a process died. +It is a port of veil's `@provablehq/aleo-bridge-sdk` 0.1.0 (registry +`2026-08-31.solana-deposits.1`) into the web3.py-style verb structure of +`aleo-sdk`. + +```sh +pip install aleo-bridge-sdk # Aleo legs only +pip install 'aleo-bridge-sdk[evm]' # + Ethereum (web3, eth-account) +pip install 'aleo-bridge-sdk[solana]' # + Solana (solders, solana) +pip install 'aleo-bridge-sdk[evm,solana]' # everything +``` + +Import name: `aleo_bridge`. + +## Quick start + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # keys + RPCs from the environment (table below) +print(bridge.status()) # addresses, balances of every bridge asset, pending transfers + +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.kind, quote.fees, quote.amount_out) # show fees + amount_out before moving anything + +progress = bridge.execute(quote.plan) # approval (if needed) + dispatch; checkpoints saved +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce="…")) +assert progress.next == "done", progress.error +``` + +`Bridge.from_profile()` instead keeps an Aleo key and a checkpoint store under +`~/.aleo-bridge/` (`$ALEO_BRIDGE_HOME`), created on first use — EVM/Solana keys +still come from the arguments or the environment and are never written to disk. + +## Connections + +```python +from aleo import Aleo, HTTPProvider +from aleo_bridge import Bridge, Ethereum, Solana + +aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) +aleo.default_account = aleo.account.from_private_key(aleo_key) + +bridge = Bridge(aleo) # Aleo legs only +bridge = Bridge(aleo, ethereum=Ethereum(ETH_RPC, private_key=evm_key), + solana=Solana(SOL_RPC, private_key=sol_key)) # raw keys +bridge = Bridge(aleo, ethereum=Ethereum(w3=my_w3, signer=my_local_account), + solana=Solana(client=my_client, signer=my_keypair)) # configured signers +bridge = Bridge(aleo, ethereum=my_w3, solana=my_client) # bare clients: read-only +``` + +| `Ethereum(...)` | Transport | Signer | +| --- | --- | --- | +| `rpc_url` + `private_key` / `signer` | `Web3(HTTPProvider(rpc_url))` | the key or `LocalAccount` | +| `w3` + `private_key` / `signer` | your `Web3` (middleware, PoA, retries) | the key or `LocalAccount` | +| `w3` alone | your `Web3` | `w3.eth.default_account` via your signing middleware, else read-only | + +| `Solana(...)` | Transport | Signer | +| --- | --- | --- | +| `rpc_url` (default `api.mainnet-beta.solana.com`) + `private_key` / `signer` | the SDK's own synchronous `SolanaRpcClient(rpc_url, commitment="confirmed")` — a `requests`-based JSON-RPC client, since solana-py ≥ 0.36 ships only an async client | base58 (Phantom export) / the 64-int `id.json` array, a solders `Keypair`, or any solana-py `Signer` | +| `client` + `private_key` / `signer` | your client reused: a `SolanaRpcClient`, a solana-py `AsyncClient` (adapted internally through `_AsyncClientAdapter`), or any duck-typed object with the same RPC methods | as above | +| `client` alone | your client | read-only | + +`Solana` supports `close()` and use as a context manager (`with Solana(...) as +solana:`) to release the wrapped client's resources; `__exit__` swallows +whatever `close()` raises, so call it directly if you need to see the error. +`Bridge.from_env()` reads `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL` and +`SOLANA_PRIVATE_KEY` (+ `SOLANA_RPC_URL`) — see the aliases and precedence +notes in the environment table below. + +## Routes + +Assets are `"chain/key"` (`"ethereum/usdc"`, `"aleo/usdcx"`); `bridge.registry` +lists everything. Active mainnet routes: + +| Route | Protocol | Minimum | Notes | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | Circle xReserve | 2 USDC | `mint_mode` public / record / **private** (you finish with `complete`) | +| `xreserve:aleo/usdcx->ethereum/usdc` | Circle xReserve | > 2 USDCx | 2 USDCx withdrawal fee; private burn (default) needs a record + exclusion proof — computed for you | +| `hyperlane:ethereum/eth->aleo/eth` / reverse | Hyperlane (native) | 1 wei | `msg.value` carries ETH + relayer fee | +| `hyperlane:ethereum/wbtc->aleo/wbtc` / reverse | Hyperlane (collateral) | 1 sat | approval + dispatch | +| `hyperlane:ethereum/usdt->aleo/usdt` / reverse | Hyperlane (collateral) | 1 µUSDT | approval reset to 0 first (USDT) | +| `hyperlane:solana/sol->aleo/sol` / `hyperlane:aleo/sol->solana/sol` | Hyperlane | 1 lamport | IGP + rent quoted live | + +Testnet: `xreserve:sepolia/usdc->aleo-testnet/usdcx` and its reverse. ALEO and +USAD routes, plus every `base`/`hyperevm` route, are `metadata-required`: +listed, refused by `quote`/`execute` until their deployments are reviewed +upstream. + +Aleo-origin Hyperlane transfers spend PUBLIC balances (`unshield` first); +Hyperlane delivers into public balances (`shield` afterwards if you want). + +**Registry fee vs. live fee — a known discrepancy.** The registry's +`xreserve:*usdcx->*usdc` literal is `withdrawalFeeAtomic = 2_000_000` (2 +USDCx), and `quote` promises exactly that. The 2026-09-18 testnet round trip +(next section) actually delivered a live xReserve withdrawal fee of +**≈1.0035 USDC**, not 2 USDC — Circle's fee is evidently dynamic and the +registry literal has not been re-measured against it. This SDK does not +change the registry literal or the quote; `amount_out` is a quote, not a +guarantee, and the same literal is used on the mainnet route, so budget for +the same gap there until someone re-measures it live. + +## The lifecycle + +```python +quote = bridge.quote(source, destination, amount="…" | amount_atomic=…, recipient=…, + sender=None, protocol=None, mint_mode="public", secret_nonce="0scalar") +progress = bridge.execute(quote.plan, on_checkpoint=save, proving="delegate", mode=None, + record=None, merkle_proof=None, gas_payment_microcredits=None, + secret_nonce=None, poll_seconds=1.0, timeout_seconds=120.0) +progress = bridge.wait(progress, until=None, poll_seconds=15.0, timeout_seconds=1200.0, on_update=None) +receipt = bridge.get_status(plan, receipt) # one refresh, no polling +progress = bridge.recover(checkpoint) # reads only +progress = bridge.resume(progress, on_checkpoint=save) +progress = bridge.complete(progress, secret_nonce="…", on_checkpoint=save) +bridge.pending() # recover() every stored checkpoint +``` + +`execute` emits a `Checkpoint` at every boundary: after each approval hash, +after proving and **before** broadcast for Aleo legs, and after broadcast. +Aleo legs prove through the delegated prover (`proving="delegate"`) or locally +(`proving="local"`) and are broadcast from the checkpointed bytes, so a crash +between proving and broadcast is resumable without proving twice. Every EVM +and Solana write checkpoints its own hash **before** polling for the receipt, +so a crash mid-poll never re-signs; `send()` is single-use per transfer — +never call it twice for the same plan, recover from the checkpoint instead. + +| `progress.next` | Status | Meaning | You do | +| --- | --- | --- | --- | +| `wait` | source confirming / attestation pending / delivery pending | in flight | `wait(progress)` | +| `resume` | `SOURCE_SUBMISSION_PENDING` | approval confirmed or proof built, transfer not submitted | `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | Circle attested; your private mint needs your signature | `complete(progress, secret_nonce=…)` | +| `done` | `COMPLETED` | delivered (destination Mailbox / nullifier / balance verified) | nothing | +| `failed` | `FAILED` / `EXPIRED` | see `progress.error` | new quote | + +A `PollingTimeoutError` from `wait` is **not** a failure: the transfer is still +in flight (`exc.progress`); call `wait` again or `recover` later. This holds +for every leg — Ethereum, Solana and Aleo receipts all return "pending", never +raise, on a polling timeout; a receipt timeout is never treated as a delivery +failure. Once the deposit / dispatch / burn is broadcast, never call `execute` +(or `send()`/`build()` again for the same call) for the same transfer — +recover from the checkpoint. + +## Recovery + +```python +from aleo_bridge import FileCheckpointStore +bridge = Bridge.from_env(checkpoints=FileCheckpointStore("~/.aleo-bridge/checkpoints")) # or BRIDGE_CHECKPOINT_DIR +for progress in bridge.pending(): # after a restart + if progress.next == "wait": progress = bridge.wait(progress) + if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) + if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=my_nonce)) +``` + +Checkpoints are an allowlist (version 1): intent, route id + registry version, +transaction ids, the proved Aleo transaction, the Solana blockhash lifetime and +the delivery baseline. Never keys, record plaintext, `secret_nonce`, +attestations, payloads or hashes. `recover` re-resolves the route from the live +registry and refuses a registry-version mismatch. Idempotent rebroadcast: a +duplicate-transaction response from the node is success. `FileCheckpointStore` +writes mode-600 files via an atomic rename; profile checkpoints (under +`~/.aleo-bridge/`) get the same treatment. `Bridge(checkpoints=store)` also +auto-saves through every lifecycle call, the same channel Ethereum, Solana and +Aleo writes all use — you don't have to pass `on_checkpoint=` yourself unless +you want a second sink. + +## Private USDCx mints + +`mint_mode="private"` commits `(recipient, secret_nonce)` on Ethereum; only the +recipient's Aleo key can `complete` the mint, with the same `secret_nonce`. The +SDK never stores the nonce (default `0scalar`). Public and record mints are +relayer-driven and finish at `done` when the bridge nullifier confirms delivery. + +## shield / unshield + +```python +bridge.shield("aleo/eth", amount="0.001").delegate() # public balance → private record (ARC-20) +bridge.unshield("aleo/usdcx", amount="5").delegate() # ARC-22: record + freeze-list exclusion proof, computed for you +bridge.freezelist.exclusion_proof(address, "usdcx_stablecoin.aleo") # the `[MerkleProof; 2]` literal itself +``` + +## Tier 2 modules + +`bridge.hyperlane.transfer_remote / quote_gas_payment / is_delivered`, +`bridge.xreserve.burn / private_mint / get_attestation / is_delivered / hook_data`, +`bridge.eth.transfer_remote / deposit_usdc / quote_transfer_remote / quote_deposit_usdc / balance / is_delivered +/ source_status / recover_source`, +`bridge.sol.transfer_remote / quote_transfer_remote / balance`. Aleo writes return an +`AleoCall` (`simulate() / prove() / delegate_prepared() / submit_prepared() / +transact() / delegate()`); EVM and Solana writes return `EvmCall` / `SolCall` +(`build()` → unsigned, `send()`). `python -m aleo_bridge` prints the full +generated reference (`AGENTS.md`). + +### Ethereum, directly + +```python +quote = bridge.eth.quote_transfer_remote("wbtc", aleo_recipient, amount="0.001") +print(quote.native_fee_atomic, quote.approval_required) + +call = bridge.eth.transfer_remote("wbtc", aleo_recipient, amount="0.001") +call.build() # unsigned tx dicts: approve(s) then transferRemote +result = call.send(on_checkpoint=store.save) # approvals → dispatch; each hash checkpointed before polling +result.message_id, result.receipt.status # Hyperlane message id, DELIVERY_PENDING + +usdc_quote = bridge.eth.quote_deposit_usdc(aleo_recipient, amount="2", mint_mode="public") +deposit = bridge.eth.deposit_usdc(aleo_recipient, amount="2", mint_mode="public").send() +deposit.message_hash # Circle attestation lookup key (receipt id), ATTESTATION_PENDING + +bridge.eth.balance("eth"); bridge.eth.is_delivered(message_id) # reads +bridge.eth.source_status(plan, receipt) # one refresh of an approval/confirming receipt +bridge.eth.recover_source(plan, checkpoint) # log-scan recovery, never signs +``` + +Routes: ETH (native), WBTC and USDT (collateral; USDT resets a non-zero +allowance to 0 first) via Hyperlane; USDC → USDCx via Circle xReserve (2 USDC +minimum, `mint_mode` public/record/private — private deposits go to the +shielded wrapper program and need the same `secret_nonce` at `complete` time; +the SDK never stores it). A receipt timeout returns a pending receipt, never a +failure. Live checks: `BRIDGE_LIVE_READS=1 ETHEREUM_RPC_URL=…` for read-only +mainnet quotes (`tests/live/test_eth_reads.py`); `BRIDGE_LIVE_FUNDS=1 +BRIDGE_LIVE_STATE_DIR=… SEPOLIA_RPC_URL=… EVM_PRIVATE_KEY=… +ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg +(`tests/live/test_eth_sepolia_leg1.py`). + +### Solana, directly + +```python +quote = bridge.sol.quote_transfer_remote(aleo_addr, amount="0.01") # amount + IGP + fee + rent, in lamports +call = bridge.sol.transfer_remote(aleo_addr, amount="0.01") +tx = call.build() # VersionedTransaction, unique-message key signed +result = call.send(on_checkpoint=store.save) # fee-payer signature, broadcast, poll to confirmed +result.message_id # Hyperlane message id from the Mailbox log +``` + +`private_key` accepts a base58 secret (Phantom export) or the 64-int JSON +array of a solana-cli `id.json`. Every read uses confirmed commitment; the +transaction sets a 400,000 compute-unit limit; the `SOURCE_CONFIRMING` receipt +(signature, unique-message address, blockhash, last valid block height) is +checkpointed before polling, and a polling timeout returns the pending receipt +rather than failing. The instruction encoding and account list are pinned +byte-for-byte against a recorded mainnet transfer +(`tests/fixtures/sealevel-transfer-remote.json`). + +Live read-only checks (no key, no funds): +`BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live/test_sol_reads.py -q -s` +decodes the live IGP account and prints a leg-11 quote for a pinned sender; +`SOLANA_RPC_URL` overrides the public default if it rate-limits. The funded +round trip runs from `scripts/rehearse.py` (see "Live rehearsal" below). + +## Agents and MCP + +```python +from aleo_bridge import bridge_tools, dispatch_tool +tools = bridge_tools() # Claude `tools=` shape; bridge_tools(include_writes=False) for read-only +dispatch_tool(bridge, "bridge_quote", {"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", "recipient": addr}) +``` + +Reads: `bridge_status`, `bridge_list_assets`, `bridge_list_routes`, `bridge_quote`, +`bridge_get_progress`, `bridge_pending`. Writes (`bridge_execute`, `bridge_resume`, +`bridge_complete`, `bridge_shield`, `bridge_unshield`) require `confirm: true` — +without it they return the quote (or recovered progress / built call) plus +`how_to_confirm`, and move nothing. `bridge_execute` takes the quote inputs and +re-quotes internally, so agents never carry plans. +`python -m aleo_bridge.mcp` serves the same tools over stdio (`[mcp]` extra). + +## Live tests and rehearsal + +The funded suite (`tests/live/`, ported from veil's `test/integration/live/`) +moves real money. It is off unless you turn it on, in your own shell, one +command at a time. `scripts/rehearse.py` drives the same case functions the +live pytest suite uses, over one route or a whole case, at minimum amounts, +and backs both the CLI and `-m live` so they cannot drift. + +**The testnet round trip has run for real, end to end, both directions** +(2026-09-18): Sepolia deposit of 3 USDC +(`0x08cd56e4a10c84d62ee000ceb20d854d2f1b6b9a6102db8f1414abfb871faab4`) into a +private USDCx mint on aleo-testnet +(`at1jdy6wyal4ndwwydy80hjxt5q9eh332vk6t8zrw8jsvgc0pg7jcxqc85day`), then a +return burn +(`at1cufuy7v4lc4dn5ek3vdfa0tpeh8rrqqnahyelv06d2ll5n0kvczsrv9ugq`) delivering a +Sepolia withdrawal +(`0xbacb3ff270f6cee401cf2aa9ce6ed3be3b9d0426b18c04a2674c86dca11e4b54`). +Recovery from the on-disk checkpoint was exercised on both legs — the process +that submitted was discarded and a fresh `Bridge` over the same +`FileCheckpointStore` finished each transfer from `bridge.pending()`. The +observed withdrawal fee (see "Registry fee vs. live fee" above) is the one +concrete correction those runs produced. Every mainnet fund-moving case has +been quoted and prechecked live (funding table below) but still runs +quote-only — mainnet execution needs the operator to fund the bridge wallets +first, which has not happened yet; this README does not carry wallet +addresses or balances. + +**Gates** (read only — nothing in this repository sets them; the exact +acknowledgement strings are the constants in `tests/live/config.py`): + +| Variable | Effect | +| --- | --- | +| `BRIDGE_LIVE_FUNDS=1` + `BRIDGE_LIVE_STATE_DIR=` | funded cases exist at all | +| `BRIDGE_LIVE_MAINNET_ACK=…` + `BRIDGE_LIVE_MAINNET_CASES=` | the named mainnet cases may run | +| `BRIDGE_LIVE_MAINNET_EXECUTE=…` | the wallet may actually submit | + +Without the last one every case runs to the quote, prints the route/amount/fee +table and returns — that is the default, and it is how you rehearse. Keys and +endpoints come from `Bridge.from_env()` (`BRIDGE_PRIVATE_KEY`, +`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`/ +`BRIDGE_SOLANA_PRIVATE_KEY`, `ETHEREUM_RPC_URL`/`BRIDGE_LIVE_ETHEREUM_RPC_URL`, +`SOLANA_RPC_URL`/`BRIDGE_LIVE_SOLANA_RPC_URL`); recipients default to your own +addresses and can be overridden with `BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT` / +`BRIDGE_LIVE_EVM_RECIPIENT` / `BRIDGE_LIVE_SOLANA_RECIPIENT`. + +**State.** Each case keeps one file at +`$BRIDGE_LIVE_STATE_DIR//-.json` (mode 600) holding +the checkpoint, the source/destination transaction ids and the message id; +the private-mint secret nonce lives beside it in `.secret` (mode 600, +created exclusively) and never in the state, a log or a checkpoint. +Re-running a case resumes from that file — recover first, then +`wait`/`resume`/`complete`; a completed case re-asserts what it recorded and +exits. A timeout is *pending*, not a failure: the checkpoint stays on disk +and the run prints the `--recover` command. + +**Cases and routes.** Five cases, each parametrized over every registry route +it covers — both directions are separate cases, and a route with +`availability != "active"` is reported as skipped-by-registry rather than +dropped: + +| Case | Routes | Amount | +| --- | --- | --- | +| `evm-hyperlane` | ethereum → aleo (ETH, WBTC, USDT) | one atomic unit | +| `aleo-hyperlane` | aleo → ethereum (ETH, WBTC, USDT), aleo → solana (SOL) | one atomic unit, `mode="signer"` | +| `solana-hyperlane` | solana → aleo (SOL) | 1 lamport | +| `evm-xreserve` | ethereum USDC → aleo USDCx | `2` USDC, private mint (needs `complete`) | +| `aleo-xreserve` | aleo USDCx → ethereum USDC | `2.000001` USDCx private burn, delivers 1 atomic unit | + +The `metadata-required` mainnet routes (ALEO on ethereum/solana/base/hyperevm, +USAD) are parametrized too and skip with `registry:metadata-required` — +`tests/live/test_lifecycle_live.py` builds its parameters by enumerating +`DEFAULT_REGISTRY.routes(...)`, so a route that no case covers raises at +import rather than disappearing. The same two xReserve case functions run the +testnet pair (`xreserve:sepolia/usdc->aleo-testnet/usdcx` at `3` USDC, then +`xreserve:aleo-testnet/usdcx->sepolia/usdc` at `2.000001`); the testnet keys +and RPC are `BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`/`ALEO_E2E_PRIVATE_KEY`, +`BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY`/`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY` +and `SEPOLIA_RPC_URL`/`BRIDGE_LIVE_SEPOLIA_RPC_URL` (public default when +unset). Testnet needs no mainnet acknowledgement — the funds gate alone. + +**Recovery is not simulated.** Every funded test runs in two phases: the +first quotes, prechecks and calls `execute` once, then returns; the client +that executed is discarded, and a brand-new `Bridge` over the same +`FileCheckpointStore` finishes the transfer from `bridge.pending()` → +`bridge.recover(checkpoint)` → `wait`/`resume`/`complete`. `execute` is never +called twice for one transfer, and `BRIDGE_LIVE_XRESERVE_AMOUNT` (or +`BRIDGE_LIVE__AMOUNT`) overrides an amount. + +**Funding per run** (from the 2026-09-17 read-only mainnet quote sweep): +ETH/WBTC/USDT Hyperlane deposits cost ≈0.0000838 ETH each in native Hyperlane +fees plus L1 gas (USDT also needs one approval); the Aleo-origin legs cost +8.174147 (ETH), 9.138947 (WBTC), 9.138947 (USDT) and 7.661056 (SOL) credits in +IGP payment plus the Aleo transaction fee, and need the asset's public +balance on Aleo; solana → aleo costs 5,647,521 lamports all-in; the xReserve +deposit needs 2 USDC plus gas, and the burn needs an unspent private USDCx +record of at least 2.000001. Return legs spend what the matching inbound leg +minted, so run inbound first — an unfunded return leg skips with its +shortfall printed rather than failing. + +**Running it.** *Quote only* — prices and prechecks every route and submits +nothing, whatever is acknowledged: + +```sh +python scripts/rehearse.py --case evm-hyperlane --quote-only +python scripts/rehearse.py --case evm-xreserve --report run.json # submits only if acknowledged +``` + +*Testnet* (Sepolia ⇄ aleo-testnet, the funds gate only — no mainnet +acknowledgement): + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_evm_xreserve_deposit +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_aleo_xreserve_return +``` + +*Mainnet, quote only* — the acknowledgement that names the cases, and +deliberately no execute variable, so each route is priced and prechecked and +nothing is signed: + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES=evm-hyperlane,evm-xreserve,aleo-hyperlane,aleo-xreserve,solana-hyperlane \ + pytest -m live -s tests/live/test_lifecycle_live.py +``` + +*Mainnet, for real* — **you** type this, in your own shell, for one command; +both acknowledgement values are the constants in `tests/live/config.py` and +appear nowhere in this repository in a copy-pasteable form. Nothing in the +suite or the CLI ever sets them: + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES= \ + BRIDGE_LIVE_MAINNET_EXECUTE= \ + pytest -m live -s "tests/live/test_lifecycle_live.py::test_evm_xreserve" +``` + +*Resuming* an interrupted transfer (the run prints this line itself): + +```sh +python scripts/rehearse.py --recover "$BRIDGE_LIVE_STATE_DIR/mainnet/-.json" +``` + +Exit codes: `0` ok, `1` a case failed, `2` something is still pending. The +harness itself (route selection, state paths, the exit-code table) is +covered hermetically by `tests/test_live_helpers.py`. + +## Environment variables + +| Variable | Used by | Meaning | +| --- | --- | --- | +| `BRIDGE_PRIVATE_KEY` | `from_env`, `from_profile` (import), MCP | Aleo private key (`APrivateKey1…`), **required** by `from_env` | +| `ALEO_ENDPOINT` | `from_env` | node API origin (default `https://edge.provable.com/api`) | +| `ALEO_NETWORK` | `from_env` | `mainnet` (default) or `testnet` | +| `ALEO_API_KEY`, `ALEO_CONSUMER_ID` | `from_env` | optional Provable credentials for legacy endpoints | +| `EVM_PRIVATE_KEY`, `ETHEREUM_RPC_URL` | `from_env`, rehearsal | Ethereum signer + RPC (both or neither); aliases `BRIDGE_EVM_PRIVATE_KEY` / `BRIDGE_LIVE_ETHEREUM_RPC_URL` — used by the user's live shell/veil config, and NOT live-test-only: ordinary `Ethereum.from_env()` reads them too, so leaving one exported points everyday calls at that endpoint; the primary variable wins when both are set | +| `SOLANA_PRIVATE_KEY`, `SOLANA_RPC_URL` | `from_env`, rehearsal | Solana signer (base58 or `id.json` array) + RPC (optional); aliases `BRIDGE_SOLANA_PRIVATE_KEY` / `BRIDGE_LIVE_SOLANA_RPC_URL`, same precedence and same everyday-call caveat as the Ethereum pair | +| `BRIDGE_CHECKPOINT_DIR` | `from_env` | bind a `FileCheckpointStore` | +| `ALEO_BRIDGE_HOME` | `from_profile` | profile directory (default `~/.aleo-bridge/`), holds only the Aleo key, mode 600 | +| `ALEO_E2E_PRIVATE_KEY` | live tests / rehearsal, testnet | testnet Aleo key (alias `BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`) | +| `BRIDGE_LIVE_FUNDS`, `BRIDGE_LIVE_STATE_DIR` | live tests, rehearsal | `1` + a directory outside the repo — funded cases exist at all | +| `BRIDGE_LIVE_MAINNET_ACK`, `BRIDGE_LIVE_MAINNET_CASES` | live tests, rehearsal | `I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS` (see `tests/live/config.py`) + `leg-3,leg-5,…` — the named mainnet cases may run | +| `BRIDGE_LIVE_MAINNET_EXECUTE` | live tests, rehearsal | `I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS` — without it every case quotes and prechecks only | + +Gates and acknowledgement strings live only in `tests/live/config.py` — nothing +in this repository sets them, and they never appear here in copy-pasteable +form. + +## Tests + +```sh +cd bridge-sdk && .venv/bin/python -m pytest -q # hermetic +.venv/bin/python -m pytest -q -m "not live" # same set, explicit marker +BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live -q # read-only mainnet checks +BRIDGE_LIVE_READS=1 BRIDGE_LIVE_SIMULATE=1 .venv/bin/python -m pytest -m live tests/live -q +``` + +Literals and vectors: `docs/veil-brief.md`. + +## Development + +```sh +cd bridge-sdk && python -m venv .venv && .venv/bin/pip install -e '.[dev]' +.venv/bin/python -m pytest -q # unit + mocked integration +.venv/bin/python codegen/gen_context.py --check # AGENTS.md is generated from docstrings +``` diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py new file mode 100644 index 0000000..90ab664 --- /dev/null +++ b/bridge-sdk/codegen/gen_context.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# bridge-sdk/codegen/gen_context.py +"""Render AGENTS.md from the SDK's docstrings — the anti-drift context page. + +Tier 1 = the lifecycle verbs + the conversation pattern; Tier 2 = the protocol +modules and the registry's route table. Run with no args to rewrite both +copies of AGENTS.md; ``--check`` exits 1 when they are stale (CI); ``--stdout`` +prints instead of writing. +""" +from __future__ import annotations + +import argparse +import inspect +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "python")) + +from aleo_bridge import Bridge # noqa: E402 +from aleo_bridge.eth import EthModule # noqa: E402 +from aleo_bridge.freezelist import FreezeList # noqa: E402 +from aleo_bridge.hyperlane import HyperlaneModule # noqa: E402 +from aleo_bridge.registry import DEFAULT_REGISTRY # noqa: E402 +from aleo_bridge.sol import SolModule # noqa: E402 +from aleo_bridge.xreserve import XReserveModule # noqa: E402 + +OUTS = [_ROOT / "AGENTS.md", _ROOT / "python" / "aleo_bridge" / "AGENTS.md"] + +TIER1 = ["from_env", "from_profile", "status", "quote", "execute", "wait", "recover", "resume", + "complete", "pending"] +TIER2 = [ + ("hyperlane.transfer_remote", HyperlaneModule.transfer_remote), + ("hyperlane.quote_gas_payment", HyperlaneModule.quote_gas_payment), + ("xreserve.burn", XReserveModule.burn), + ("xreserve.private_mint", XReserveModule.private_mint), + ("xreserve.get_attestation", XReserveModule.get_attestation), + ("shield", Bridge.shield), + ("unshield", Bridge.unshield), + ("freezelist.exclusion_proof", FreezeList.exclusion_proof), + ("eth.transfer_remote", EthModule.transfer_remote), + ("eth.deposit_usdc", EthModule.deposit_usdc), + ("eth.quote_transfer_remote", EthModule.quote_transfer_remote), + ("sol.transfer_remote", SolModule.transfer_remote), + ("sol.quote_transfer_remote", SolModule.quote_transfer_remote), +] + +QUICKSTART = """\ +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +```""" + +CONVERSATION_PATTERN = """\ +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. +""" + + +def _entry(name: str, fn: object) -> str: + try: + sig = str(inspect.signature(fn)) # type: ignore[arg-type] + except (TypeError, ValueError): + sig = "(...)" + doc = inspect.getdoc(fn) or "" + return f"### `{name}{sig}`\n\n{doc.strip()}\n" + + +def _route_table() -> list[str]: + rows = ["| Route id | Protocol | Environment | Availability |", "| --- | --- | --- | --- |"] + for route in DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None): + rows.append(f"| `{route.id}` | {route.protocol} | {route.environment} | {route.availability} |") + return rows + + +def render() -> str: + parts = [ + "# aleo-bridge — agent guide", + "", + "> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not", + "> edit by hand; edit the docstrings and regenerate.", + "", + "Typed Python client that moves assets between Aleo, Ethereum and Solana", + "over the reviewed Hyperlane warp routes and Circle xReserve deployments", + "(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`).", + "MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as", + "tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas.", + f"Registry version `{DEFAULT_REGISTRY.version}`.", + "", + "## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked)", + "", + QUICKSTART, + "", + ] + parts += [_entry(n, getattr(Bridge, n)) for n in TIER1] + parts += [ + CONVERSATION_PATTERN, + "", + "## Tier 2 — the protocol modules (building your own flows)", + "", + "Every Aleo write returns an `AleoCall`: nothing touches the network until", + "`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not", + "broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local", + "proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana", + "writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`.", + "The lifecycle verbs above compose these; use them directly only when you", + "need a single leg. Confirm-gated writes and the never-resend rule above", + "apply here too — these are the same broadcasts, just one leg at a time.", + "", + ] + parts += [_entry(name, fn) for name, fn in TIER2] + parts += ["### Routes in the pinned registry", ""] + parts += _route_table() + parts += ["", "`metadata-required` routes are listed but refused by `quote`/`execute`", + "until their deployments are reviewed upstream.", ""] + return "\n".join(parts) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", help="exit 1 when AGENTS.md is stale (CI gate)") + ap.add_argument("--stdout", action="store_true") + args = ap.parse_args() + page = render() + if args.stdout: + print(page, end="") + return 0 + if args.check: + for out in OUTS: + current = out.read_text() if out.exists() else "" + if current != page: + print(f"{out} is stale — run: python codegen/gen_context.py", file=sys.stderr) + return 1 + return 0 + for out in OUTS: + out.write_text(page) + print(f"wrote {out} ({len(page)} chars)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bridge-sdk/docs/veil-brief.md b/bridge-sdk/docs/veil-brief.md new file mode 100644 index 0000000..9924f6f --- /dev/null +++ b/bridge-sdk/docs/veil-brief.md @@ -0,0 +1,326 @@ +# Veil Bridge (`@provablehq/aleo-bridge-sdk` v0.1.0) — Capability Port Brief + +Source: `/Users/privacydaddy/dev/aleo-viem/packages/bridge`. Deps: viem ^2.21, bs58 ^6, @provablehq/veil-core. Optional peers: @provablehq/sdk (private xReserve mints + program-address derivation), @solana/kit ^8. + +## 1. REGISTRY + +### 1.1 Data model (`src/types/protocol.ts`) +``` +BridgeProtocol = 'xreserve' | 'hyperlane' +BridgeEnvironment = 'mainnet' | 'testnet' +BridgeChainFamily = 'aleo' | 'evm' | 'solana' +BridgeAssetKind = 'native' | 'token' +BridgeRouteAvailability = 'active' | 'metadata-required' | 'disabled' +AleoPrivacyKind = 'arc20' | 'arc22' +AleoMintMode = 'public' | 'record' | 'private' +BridgeStepExecutor = 'aleo-wallet' | 'evm-wallet' | 'solana-wallet' | 'protocol' +BridgeExecutionStepKind = approve|deposit|burn|dispatch|wait-attestation|mint|withdraw|wait-delivery|confirm-delivery + +ProtocolBridgeChain { id, displayName, family, environment, nativeCurrencySymbol, protocolDomains?: {xreserve?, hyperlane?} } +BridgeAssetLocator { kind: 'aleo-program'|'evm-contract'|'solana-mint'|'native', value, tokenId? } +AleoPrivacyCapability{ kind: 'arc20'|'arc22', program } +ProtocolBridgeAsset { id, key, chainId, symbol, name, decimals, kind, locator?, addressValidationRegex?, privacy? } +ProtocolBridgeRoute { id, protocol, environment, sourceAssetId, destinationAssetId, availability, deploymentId?, source?, metadata? } +BridgeRegistry { version, chains[], assets[], routes[], sources[], getAssets(), getRoutes() } +``` +getAssets({environment?, chainId?, symbol?}) case-insensitive. getRoutes({environment?, protocol?, sourceChainId?, destinationChainId?, symbol?, includeUnavailable?}) excludes disabled unless includeUnavailable; metadata-required always visible; symbol matches source OR destination. + +Registry version: `'2026-08-31.solana-deposits.1'`. +``` +EVM_ADDRESS = '^0x[0-9a-fA-F]{40}$' +SOLANA_ADDRESS = '^[1-9A-HJ-NP-Za-km-z]{32,44}$' +ALEO_ADDRESS = '^aleo1[0-9a-z]{58}$' +``` + +### 1.2 Validation +Unique chain/asset/route ids; asset chainId exists; scoped key `${chainId}/${key}` unique; decimals int>=0; regex compiles; privacy only on aleo family. Route source/dest assets exist; both chains' environment == route.environment. Solana gate: active hyperlane route with solana source requires warpProgramAddress, tokenPda, nativeCollateralPda, dispatchAuthorityPda, mailboxProgramAddress, mailboxOutboxPda, igpProgramAddress, igpProgramDataPda, igpAccount, splNoopProgramAddress, destinationDomain (number), destinationGasAmount, registryCommit, solanaReviewedAt, solanaConfigSource. igpOverheadAccount optional. + +### 1.3 Chains +| id | family | env | native | protocolDomains | +|---|---|---|---|---| +| aleo | aleo | mainnet | ALEO | xreserve 10002, hyperlane 1634493807 | +| ethereum | evm | mainnet | ETH | xreserve 0, hyperlane 1 | +| solana | solana | mainnet | SOL | hyperlane 1399811149 | +| base | evm | mainnet | ETH | — | +| hyperevm | evm | mainnet | HYPE | — | +| aleo-testnet | aleo | testnet | ALEO | xreserve 10002, hyperlane 1617853565 | +| sepolia | evm | testnet | ETH | hyperlane 11155111 | + +### 1.4 Assets +| id | symbol | dec | kind | locator | tokenId | privacy | +|---|---|---|---|---|---|---| +| aleo/aleo | ALEO | 6 | native | aleo-program credits.aleo | | | +| aleo/usdcx | USDCx | 6 | token | aleo-program usdcx_stablecoin.aleo | | arc22 usdcx_stablecoin.aleo | +| aleo/eth | ETH | 18 | token | aleo-program hyp_warp_token_eth_v2.aleo | aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8 | arc20 arc20_eth.aleo | +| aleo/wbtc | WBTC | 8 | token | aleo-program hyp_warp_token_wbtc_v2.aleo | aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf | arc20 arc20_wbtc.aleo | +| aleo/usdt | USDT | 6 | token | aleo-program hyp_warp_token_usdt_v2.aleo | aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y | arc20 arc20_usdt.aleo | +| aleo/sol | SOL | 9 | token | aleo-program hyp_warp_token_sol_v2.aleo | aleo1aa0zt0vg9uwknekpqeefkvad55swp7833wc5crp2prv0lm4djuxs5r7k6v | arc20 arc20_sol.aleo | +| aleo/usad | USAD | 6 | token | aleo-program usad_stablecoin.aleo | | | +| ethereum/usdc | USDC | 6 | token | evm 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | | | +| ethereum/eth | ETH | 18 | native | native ETH | | | +| ethereum/wbtc | WBTC | 8 | token | evm 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 | | | +| ethereum/usdt | USDT | 6 | token | evm 0xdAC17F958D2ee523a2206206994597C13D831ec7 | | | +| ethereum/aleo | ALEO | 6 | token | (none) | | | +| ethereum/usad | USAD | 6 | token | (none) | | | +| solana/sol | SOL | 9 | native | native SOL | | | +| solana/aleo, base/aleo, hyperevm/aleo | ALEO | 6 | token | (none) | | | +| aleo-testnet/usdcx | USDCx | 6 | token | aleo-program test_usdcx_stablecoin.aleo | | arc22 test_usdcx_stablecoin.aleo | +| sepolia/usdc | USDC | 6 | token | evm 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 | | | + +### 1.5 Routes (22 total). Route id = `${protocol}:${sourceAssetId}->${destinationAssetId}` +``` +XRESERVE_SOURCE = 'https://developers.circle.com/xreserve/references/supported-blockchains-and-domains' +HYPERLANE_REGISTRY_COMMIT = '2621c16f2db1ccb46643265c110dac5ca2c7c51a' +HYPERLANE_SOURCE = 'https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes' +``` + +**A. xReserve mainnet** `xreserve:ethereum/usdc->aleo/usdcx` + reverse, active, deploymentId 'xreserve-usdcx-aleo': +``` +xReserveContract 0x8888888199b2Df864bf678259607d6D5EBb4e3Ce ; sourceChainId 1 ; sourceDomain 0 +ethereumDestinationDomain 0 ; arcDestinationDomain 26 ; remoteDomain 10002 +remoteToken usdcx_stablecoin.aleo +remoteTokenBytes32 0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711 +minimumAmountAtomic '2000000' ; withdrawalFeeAtomic '2000000' ; maxFeeAtomic '100000' +bridgeProgram usdcx_bridge_v2.aleo ; wrapperProgram shielded_usdcx_wrapper.aleo +attestationBaseUrl https://xreserve-api.circle.com/v1/attestations +``` +**B. xReserve testnet** `xreserve:sepolia/usdc->aleo-testnet/usdcx` + reverse, active, 'xreserve-usdcx-aleo-testnet': +``` +xReserveContract 0x008888878f94C0d87defdf0B07f46B93C1934442 ; sourceChainId 11155111 ; sourceDomain 0 +ethereumDestinationDomain 0 ; arcDestinationDomain 26 ; remoteDomain 10002 +remoteToken test_usdcx_stablecoin.aleo +remoteTokenBytes32 0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401 +minimumAmountAtomic '2000000' ; withdrawalFeeAtomic '2000000' ; maxFeeAtomic '100000' +bridgeProgram test_usdcx_bridge_v2.aleo ; wrapperProgram shielded_usdcx_wrapper.aleo +attestationBaseUrl https://xreserve-api-testnet.circle.com/v1/attestations +``` +**ETHEREUM_HYPERLANE_COMMON** (every Ethereum<->Aleo Hyperlane route): +``` +sourceChainId 1 ; destinationDomain 1634493807 +mailboxAddress 0xc005dc82818d67AF737725bD4bf75435d065D239 +interchainGasPaymaster 0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611 +interchainSecurityModule 0x0000000000000000000000000000000000000000 +registryCommit 2621c16f2db1ccb46643265c110dac5ca2c7c51a +``` +**ALEO_MAILBOX_METADATA** (every Hyperlane route): +``` +aleoHookManagerProgram hyp_hook_manager.aleo ; aleoMailboxProgram hyp_mailbox.aleo ; aleoMailboxProgramEdition 0 +aleoMailboxLocalDomain 1634493807 ; aleoMailboxObservedNonce 170 ; aleoMailboxObservedProcessCount 291 +aleoMailboxDefaultIsm aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk +aleoMailboxDefaultHook aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74 (= IGP identity in gas-config key) +aleoMailboxRequiredHook aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82 +aleoMailboxDispatchProxy aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s +aleoMailboxOwner aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8 +aleoMailboxMetadataReviewedAt 2026-08-17 ; aleoMailboxStateVerified true +``` +**C. hyperlane:ethereum/eth->aleo/eth** active 'ETH/aleo': COMMON + routerAddress 0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A, routerType native, destinationRouter hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8 + MAILBOX. +**D. hyperlane:aleo/eth->ethereum/eth** active: +``` +aleoRouterProgram hyp_warp_token_eth_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 0 ; aleoTokenType '1' +aleoTokenOwner aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r +aleoIsm = aleoHook = aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc +aleoTokenId 133188123661477349522757068766864658505569365361420630212878794317749195359field +aleoLocalDecimals 18 ; aleoRemoteDecimals 18 +aleoRemoteRouterEvmAddress 0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A +aleoRemoteRouterRecipient [0u8 x12, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8] +aleoRemoteRouterGas '44000' +aleoAllowanceSpender0 aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74 ; Spender1..3 = aleo1qqq…3ljyzc ; Amount0..3 '0' +aleoRecipient '[0u128, 0u128]' (placeholder) ; aleoPlaceholderConfiguration false ; aleoWithdrawalReviewedAt 2026-08-26 +``` +**E. hyperlane:ethereum/wbtc->aleo/wbtc** active 'WBTC/aleo': COMMON + routerAddress 0x20CDC85778b732073F7EecEF3DF25c0d310f8772, routerType collateral, tokenAddress 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, destinationRouter hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf + MAILBOX. +**F. hyperlane:aleo/wbtc->ethereum/wbtc** active: +``` +aleoRouterProgram hyp_warp_token_wbtc_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 0 ; aleoTokenType '1' +aleoTokenOwner aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95 +aleoTokenId 1505227928464760254508513036497943623956572091841806589002910775534260084309field +aleoLocalDecimals 8 ; aleoRemoteDecimals 8 +aleoRemoteRouterEvmAddress 0x20CDC85778b732073F7EecEF3DF25c0d310f8772 +aleoRemoteRouterRecipient [0u8 x12, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8] +aleoRemoteRouterGas '68000' +``` +**G. hyperlane:ethereum/usdt->aleo/usdt** active 'USDT/aleo': COMMON + routerAddress 0x3C2064D78e4578E8F936E3db42aEF044E33FBF31, routerType collateral, tokenAddress 0xdAC17F958D2ee523a2206206994597C13D831ec7, destinationRouter hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y, **requiresApprovalReset true** + MAILBOX. +**H. hyperlane:aleo/usdt->ethereum/usdt** active: +``` +aleoRouterProgram hyp_warp_token_usdt_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 1 ; aleoTokenType '1' +aleoTokenOwner aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z +aleoTokenId 8295938150000417034830036849466229528602563851235385582732969109393809606969field +aleoLocalDecimals 6 ; aleoRemoteDecimals 18 ; aleoScale '1000000000000' +aleoRemoteRouterEvmAddress 0x3C2064D78e4578E8F936E3db42aEF044E33FBF31 +aleoRemoteRouterRecipient [0u8 x12, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8] +aleoRemoteRouterGas '68000' +``` +**I. hyperlane:solana/sol->aleo/sol** active 'SOL/aleo': +``` +warpProgramAddress 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 +tokenPda JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW +nativeCollateralPda 8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC +dispatchAuthorityPda ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY +mailboxProgramAddress E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi +mailboxOutboxPda BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk +igpProgramAddress BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv +igpProgramDataPda 8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho +igpAccount JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M +igpOverheadAccount AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF +splNoopProgramAddress noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV +destinationDomain 1634493807 ; destinationGasAmount '464000' +registryCommit 418056e21734d26a7d14692e0ec5e902cc9e86bf ; solanaReviewedAt 2026-08-31 +``` +**J. hyperlane:aleo/sol->solana/sol** active: +``` +aleoRouterProgram hyp_warp_token_sol_v2.aleo ; aleoDestinationDomain 1399811149 ; aleoTokenType '1' +aleoTokenOwner aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45 +aleoTokenId 6148061383892805373029428966764338809222769879628268522058032128225601478383field +aleoLocalDecimals 9 ; aleoRemoteDecimals 9 +aleoRemoteRouterSolanaAddress 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 +aleoRemoteRouterRecipient [112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8] +aleoRemoteRouterGas '300000' +``` +**K–N.** metadata-required ALEO pairs ('ALEO/aleo'): aleo/aleo<->ethereum/aleo, <->solana/aleo, <->base/aleo, <->hyperevm/aleo (MAILBOX only). +**O/P.** hyperlane:ethereum/usad->aleo/usad and reverse: metadata-required ('USAD/aleo'); reverse uses placeholders (aleoPlaceholderConfiguration true) — execution refuses. +Placeholder scaffold: ALEO_PLACEHOLDER_ADDRESS aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n; aleoTokenType '0'; aleoTokenId '0field'; recipient zeros; gas '0'. +Testnet registry = only the two Sepolia<->aleo-testnet xReserve routes. No testnet Hyperlane route. + +## 2. LIFECYCLE ACTIONS + +### 2.1 prepare(registry, params) -> BridgePlan (pure) +Inputs {source:{chain,asset}, destination:{chain,asset}, bridgeProtocol?, amount, recipient, sender?, mintMode?}. Filters routes on exact asset pair, not disabled, optional protocol; 0 or >1 matches → error. mintMode default 'public'; non-public only for xreserve with aleo destination. Amount parsed with source decimals (>0) and re-parsed with destination decimals (precision). Recipient regex-checked. fees: []. +Steps: xReserve EVM→Aleo: source-approval(reversible) → source-deposit(IRREVERSIBLE) → deposit-attestation(protocol) → destination-mint(aleo-wallet iff private else protocol). xReserve Aleo→EVM: source-burn(IRREVERSIBLE) → withdrawal-attestation → destination-withdrawal → destination-confirmation. Hyperlane: optional source-approval (token on non-aleo source) → source-dispatch(IRREVERSIBLE) → message-delivery → destination-confirmation. + +### 2.2 quote → kinds evm-hyperlane | solana-hyperlane | aleo-hyperlane | evm-xreserve | aleo-xreserve. aleo-xreserve quote makes NO network call: amountOut = amount − withdrawalFee (source decimals); fee entry {kind:'protocol', estimated:false}; throws if amount <= fee. + +### 2.3 execute params {plan, pollingIntervalMs?, confirmationTimeoutMs?, onCheckpoint?, mode?, userRecord?, merkleProof?, privateFee?, gasPaymentMicrocredits?, privateMintSecretNonce?, onProgress?}. Aleo Hyperlane mode 'caller'|'signer'; Aleo xReserve mode 'private'|'public'|'public-as-signer'. Two checkpoint hooks: post-proving pre-broadcast (status SOURCE_SUBMISSION_PENDING, protocolState.preparedTransaction = serialized tx) and post-broadcast. Aleo-Hyperlane reads destination balance BEFORE submitting (destinationBalanceBeforeAtomic, expectedDestinationIncreaseAtomic) and re-quotes IGP right before proving unless pinned (hook asserts exact equality). + +### 2.4 getStatus({plan, receipt}) — guards receipt.protocol == plan.protocol && protocolState.routeId == plan.route.id; terminal (COMPLETED|FAILED|EXPIRED) returned untouched. Branches: +1. SOURCE_APPROVAL_PENDING+evm → tx receipt; reverted → FAILED; success → SOURCE_SUBMISSION_PENDING. +2. SOURCE_CONFIRMING+aleo → transactionStatus; accepted → DELIVERY_PENDING; rejected → FAILED. +3/4. SOURCE_CONFIRMING+hyperlane evm/solana → protocol getSourceStatus (extract messageId). +5. DELIVERY_PENDING+hyperlane+messageId+dest aleo|evm → readHyperlaneDelivery on dest Mailbox → COMPLETED. +6. DELIVERY_PENDING+hyperlane+source aleo → balance-diff fallback (current >= before + expected → COMPLETED). +7. other hyperlane → unchanged. 8. xReserve Aleo→EVM DELIVERY_PENDING → unchanged. 9. else → 'Status refresh is not implemented'. +10. xReserve EVM→Aleo: for ATTESTATION_PENDING|DELIVERY_PENDING|DESTINATION_ACTION_REQUIRED first read Aleo nullifier → COMPLETED if delivered. SOURCE_CONFIRMING → source status; ATTESTATION_PENDING → Circle getAttestation (pending → unchanged; complete+non-private → DELIVERY_PENDING + attestation; complete+private → DESTINATION_ACTION_REQUIRED + nextAction {kind:'xreserve-private-mint'}); DESTINATION_CONFIRMING → Aleo tx status → COMPLETED/FAILED. + +### 2.5 BridgeProgress.next +``` +SOURCE_SUBMISSION_PENDING -> 'resume' +DESTINATION_ACTION_REQUIRED -> 'complete' +COMPLETED -> 'done' +FAILED | EXPIRED -> 'failed' +else -> 'wait' +``` +Statuses: PREPARED, SOURCE_APPROVAL_PENDING, SOURCE_SUBMISSION_PENDING, SOURCE_CONFIRMING, ATTESTATION_PENDING, DESTINATION_ACTION_REQUIRED, DELIVERY_PENDING, DESTINATION_CONFIRMING, COMPLETED, FAILED, EXPIRED. + +### 2.6 wait({progress, until?, pollingIntervalMs=15000, timeoutMs=1200000, onUpdate?}) — always stops at SOURCE_SUBMISSION_PENDING, DESTINATION_ACTION_REQUIRED, COMPLETED, FAILED, EXPIRED ∪ until. Timeout → 'Bridge status polling timed out in state X'. + +### 2.7 recover({checkpoint}) — version must be 1; re-runs prepare(checkpoint.intent); checks route id and registryVersion. Aleo source: prepared-but-unbroadcast → next 'resume' with preparedTransaction; else requires transactionId → one getStatus from SOURCE_CONFIRMING. Solana: requires transactionId; validates (blockhash,lastValidBlockHeight) both-or-neither. EVM Hyperlane: recoverSourceCheckpoint (log scan). EVM xReserve: recoverSourceCheckpoint; submitted destination tx → DESTINATION_CONFIRMING; prepared destination tx preserved only if still DESTINATION_ACTION_REQUIRED. + +### 2.8 resume({progress, ...}) — requires next=='resume' and SOURCE_SUBMISSION_PENDING. Aleo: rebroadcast identical bytes; DuplicateTransaction = success. EVM: protocol execute with resume receipt (re-scans history before new dispatch). + +### 2.9 complete — xReserve private mint only; requires DESTINATION_ACTION_REQUIRED + nextAction xreserve-private-mint; re-validates payload/messageHash/attestation; rebroadcasts preparedDestinationTransaction if present else calls private_mint. + +### 2.10 createBridgeCheckpoint(plan, receipt) — ALLOWLIST (version 1): +``` +intent: { source, destination, bridgeProtocol, amount, recipient, sender?, mintMode? (only aleo-program dest) } +route: { id, registryVersion } +source?: { approvalTransactionIds?, transactionId?, hookData?, blockhash?, lastValidBlockHeight?, preparedTransaction?: {transactionId, serializedTransaction} } +destination?: { transactionId?, preparedTransaction? } +deliveryVerification?: { balanceBeforeAtomic, expectedIncreaseAtomic } +``` +EXCLUDED: private keys, record plaintext, secretNonce, attestation bodies, amountAtomic, maxFeeAtomic, remoteRecipientBytes32, messageHash, payload, nonce. + +### 2.11 shield / unshield — asset must be aleo family with privacy. amount → `${atomic}u128` (>0). +shield: ARC-22 → transfer_public_to_private(recipient, amount); ARC-20 → shield(amount). +unshield: ARC-22 → transfer_private_to_public(recipient, amount, record, merkleProof ?? EMPTY); ARC-20 → unshield(record, amount). +EMPTY_MERKLE_PROOF_PAIR = `[{ siblings: [0field x16], leaf_index: 1u32 }, { siblings: [0field x16], leaf_index: 1u32 }]` (16 siblings each, comma+space joined). + +### 2.12 Errors — single `BridgeError(message, {cause})`. No code taxonomy. + +## 3. PROTOCOL MECHANICS + +### 3.1 EVM Hyperlane deposit +ABI: quoteTransferRemote(uint32,bytes32,uint256) view returns ((address token,uint256 amount)[]); transferRemote(uint32,bytes32,uint256) payable returns (bytes32); event SentTransferRemote(uint32 indexed destination, bytes32 indexed recipient, uint256 amount); allowance/approve; event DispatchId(bytes32 indexed messageId). +Quote: assert eth_chainId == sourceChainId. recipientBytes32 = aleoAddressToBytes32(recipient). Call quoteTransferRemote; nativeValueAtomic = Σ quotes with token == 0x0. Native route: nativeValue >= amount; fee = nativeValue − amount; msg.value = nativeValue (carries ETH + fee). Collateral: tokenAmount = Σ quotes with token == tokenAddress (>= amount); fee = nativeValue; msg.value = nativeValue. +Execute: defaults poll 1000ms / timeout 120000ms. Fresh quote at last moment. Wallet addr must equal plan.sender. Approval (collateral): allowance < required → if allowance > 0 && requiresApprovalReset: approve(router, 0) first; then approve(router, required). Checkpoint each hash BEFORE polling. Dispatch: transferRemote(destinationDomain, recipientBytes32, amountAtomic) value=nativeValue to router. Checkpoint at SOURCE_CONFIRMING before polling. On success, messageId from DispatchId log → DELIVERY_PENDING, id = messageId ?? txHash. protocolState {routeId, approvalTxIds, sourceSender, recipientBytes32, destinationDomain, nativeValueAtomic, amountAtomic}. +Recovery: getLogs(router, fromBlock=highest approval block) decode SentTransferRemote matching destination/recipient/amount; tx from == sender && to == router; >1 match → error. + +### 3.2 EVM xReserve USDC deposit +ABI: depositToRemote(uint256 value, uint32 remoteDomain, bytes32 remoteRecipient, address localToken, uint256 maxFee, bytes hookData); event DepositedToRemote(address indexed localToken, uint256 value, address indexed localDepositor, bytes32 indexed remoteRecipient, uint32 remoteDomain, bytes32 remoteToken, uint256 maxFee, bytes hookData). +Quote: amount >= minimumAmountAtomic; hookData; remoteRecipientBytes32 = public/record → aleoAddressToBytes32(recipient); PRIVATE → aleoAddressToBytes32(programAddress(wrapperProgram)). balanceOf + allowance; approvalRequired = allowance < amount. +Execute: approve(xReserve, amount) if needed; depositToRemote(amount, remoteDomain, remoteRecipientBytes32, tokenAddress, maxFeeAtomic, hookData) — NO msg.value. Confirm: find DepositedToRemote log from xReserve contract, re-verify every field. logIndex → nonce = calculateXReserveDepositNonce(sourceDomain, txHash, logIndex); payload = buildXReserveDepositPayload(...); messageHash = keccak256(payload). Receipt id = messageHash, ATTESTATION_PENDING, protocolState += {sourceDomain, remoteDomain, depositLogIndex, nonce, payload, messageHash, bridgeProgram, wrapperProgram}. +Attestation: GET {attestationBaseUrl}/{messageHash}; 404 → pending; other non-ok → error. Body {attestation:{payload, messageHash, attestation}}; verify echoed hash and keccak256(payload) == messageHash. +Hook byte 0: 0 public, 1 record, 2 private. + +### 3.3 Aleo Hyperlane transfer_remote +Program = aleoRouterProgram; fn 'transfer_remote' (mode caller) | 'transfer_remote_as_signer' (mode signer). 7 inputs: +``` +0 "{ token_type: {T}u8, token_owner: {owner}, ism: {ism}, hook: {hook}, token_id: {tokenId}, local_decimals: {ld}u8, remote_decimals: {rd}u8 }" +1 "{ default_hook: {aleoMailboxDefaultHook}, required_hook: {aleoMailboxRequiredHook} }" +2 "{ domain: {dest}u32, recipient: {aleoRemoteRouterRecipient}, gas: {aleoRemoteRouterGas}u128 }" +3 "{dest}u32" +4 "[{limb0}u128, {limb1}u128]" (evm/solana address → limbs) +5 "{amountAtomic}u128" +6 "[{ spender: S0, amount: {gasPayment}u64 }, { spender: S1, amount: 0u64 }, { spender: S2, amount: 0u64 }, { spender: S3, amount: 0u64 }]" +``` +Refuses placeholder config, non-active route, missing gasPayment. Receipt SOURCE_CONFIRMING protocolState {routeId, sourceProgram, sourceFunction}. +IGP quote: read hyp_hook_manager.aleo mapping destination_gas_configs key `{ igp: {aleoMailboxDefaultHook}, destination: {domain}u32 }` → struct {gas_overhead, exchange_rate, gas_price}. gasLimit = aleoRemoteRouterGas (0 → 50000). payment = ((gasLimit + gasOverhead) * gasPrice * exchangeRate) // 10_000_000_000 ; 0 < payment <= 2^64-1. Vector: gas_overhead 159337, exchange_rate 402, gas_price 1000000000, gasLimit 44000 → 8174147. + +### 3.4 Aleo xReserve burn +ethereumDestinationDomain must be 0. amount > withdrawalFeeAtomic strictly. args amount "{atomic}u128", nativeDomain "0u32", nativeRecipient = xReserveHexToAleoBytes(evmAddressToXReserveBytes32(recipient), 32). +| mode | program | fn | inputs | +| private (default) | wrapperProgram shielded_usdcx_wrapper.aleo | private_burn | [userRecord, amount, nativeDomain, nativeRecipient, merkleProof] | +| public | bridgeProgram usdcx_bridge_v2.aleo | burn_public | [amount, nativeDomain, nativeRecipient] | +| public-as-signer | bridgeProgram | burn_public_as_signer | same | +Private: userRecord required (Token record of remoteToken program); merkleProof `[MerkleProof; 2]` literal required (no default). Receipt SOURCE_CONFIRMING; protocolState {routeId, burnMode, amountAtomic, nativeDomain, nativeRecipientBytes32, sourceProgram, sourceFunction, forwardingService:'aleo-burn-attestation'}. + +### 3.5 Aleo xReserve private mint +Preconditions: attestation complete; payload/messageHash match; keccak256(attestation.payload) == messageHash. Secret nonce check: expectedHookData = buildXReserveHookData('private', recipient, env, secretNonce); attestedHookData = '0x' + payload.slice(-130) (last 65 bytes); must match. secretNonce default '0scalar'. +Call wrapperProgram.private_mint with 5 inputs: [xReserveHexToAleoBytes(payload,305), xReserveHexToAleoBytes(attestation,65), xReserveHexToAleoBytes(messageHash,32), secretNonce, recipient]. Receipt DESTINATION_CONFIRMING. + +### 3.6 Solana Hyperlane SOL transfer_remote +Instruction data 77 bytes LE: [0..8) 01x8 discriminator; [8] 0x01 variant; [9..13) destination u32 LE; [13..45) recipient 32 bytes (aleoAddressToBytes32, no reversal); [45..77) amount u256 LE. +PDAs: dispatched-message = PDA(mailboxProgram, ['hyperlane','-','dispatched_message','-', uniqueMessagePubkey]); gas-payment = PDA(igpProgram, ['hyperlane_igp','-','gas_payment','-', uniqueMessagePubkey]). +Accounts (16; 15 without overhead): +``` +0 System 11111111111111111111111111111111 ro ; 1 splNoop ro ; 2 tokenPda ro ; 3 mailboxProgram ro ; 4 mailboxOutboxPda rw +5 dispatchAuthorityPda ro ; 6 sender signer rw ; 7 uniqueMessage signer ro ; 8 dispatchedMessagePda rw +9 igpProgram ro ; 10 igpProgramDataPda rw ; 11 gasPaymentPda rw ; 12 igpOverheadAccount ro (OPTIONAL, omitted if absent) +13 igpAccount rw ; 14 System ro ; 15 nativeCollateralPda rw +``` +IGP account decode: [1B initialized][8B "IGP_____"][1B bump][32B salt][1B owner Option tag (+32B)][32B beneficiary][4B count u32 LE]; entries 38B: [4B domain u32][1B oracle tag must be 0][16B token_exchange_rate u128][16B gas_price u128][1B token_decimals]. dest_cost = gasAmount*gas_price; origin_cost = dest_cost*exchange_rate // 10^19; lamports = origin_cost * 10^(9−dec) if dec<=9 else // 10^(dec−9). gasAmount = destinationGasAmount '464000'. Vector: exchange_rate 751705303136, gas_price 83169, dec 6 → 2_900_000 lamports. +Quote: IGP account data, throwaway unique signer, blockhash (confirmed), v0 message with compute unit limit 400_000; getFeeForMessage; rent for 141, 194, 0 bytes; total = amount + igp + fee + rent. plan.sender required. +Execute: wallet addr == plan.sender; re-quote; preflight balance; partial sign with unique-message keypair; wallet signs fee payer; send. Checkpoint SOURCE_CONFIRMING with {routeId, signature, uniqueMessageAddress, destinationDomain, quotedLamports, blockhash, lastValidBlockHeight} BEFORE polling. Poll getSignatureStatuses(searchTransactionHistory); failed → throw; confirmed|finalized → done; else isBlockhashValid → 'expired'; read exceptions swallowed. Message id: getTransaction logs (maxSupportedTransactionVersion 0, commitment confirmed), regex `Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})`; absence → messageIdUnavailable true. + +### 3.7 Delivery verification +- Hyperlane → Aleo: mapping `deliveries` on hyp_mailbox.aleo key `{ id: [{lo}u128, {hi}u128] }` with lo = LE u128 of bytes[0..16), hi = bytes[16..32). Non-null → delivered. Vector: 0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d → `{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }`. +- Aleo → EVM: Mailbox `delivered(bytes32) view returns (bool)`. +- Aleo → Solana: balance-diff fallback only. +- xReserve → Aleo: mapping `nullifier` on bridgeProgram key xReserveHexToAleoBytes(nonce, 32); value 'true' → delivered. +- Hyperlane explorer never used in production status. + +### 3.8 Encoders +aleoAddressToBytes32: 'aleo1' prefix, len 63, bech32m (const 0x2bc830a3, alphabet qpzry9x8gf2tvdw0s3jn54khce6mua7l, generators 3b6a57b2 26508e6d 1ea119fa 3d4233dd 2a1462b3), 32 bytes. Vector aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n → 0xb102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811. +bytes32ToAleoAddress: inverse. +evmAddressToXReserveBytes32: left-pad checksummed address to 32. +evmAddressToAleoHyperlaneRecipient: pad to 32, limbs LE u128 of [0..16),[16..32). Vector 0x1e196d0a7d8189054c4db744ab3340c3f1c68b19 → [13858749752514421660238621190289096704, 33956464229475118999063216025592496509]; 0x…0001 → [0, 1329227995784915872903807060280344576]. +solanaAddressToAleoHyperlaneRecipient: bs58 decode 32 bytes, same limbs. 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 → [127878782877948140186055645953777992816, 163261512394675613100746600600636171918]. +calculateXReserveDepositNonce = keccak256(abi.encode(uint32 sourceDomain) || txHash(32) || abi.encode(uint256 logIndex)). +buildXReserveDepositPayload (305 bytes): [0..8) 5a2e0acd00000001; [8..40) amount u256 BE; [40..44) remoteDomain u32 BE; [44..76) remoteToken; [76..108) remoteRecipient; [108..140) localToken padded; [140..172) depositor padded; [172..204) maxFee u256 BE; [204..236) nonce; [236..240) 0x00000041; [240..305) hookData 65. +buildXReserveHookData(mode, recipient, env, secretNonce='0scalar'): 65 bytes; byte0 = 0/1/2; private: bytes[1..33) = BHP256.commit(Plaintext(recipient).toBitsLe(), Scalar(secretNonce)).toBytesLe(). +xReserveHexToAleoBytes(hex, n): `[0u8,255u8]` NO spaces. +parseDecimalAmount / formatDecimalAmount: strict regex `^(\d+)(?:\.(\d+))?$`, frac digits <= decimals; format strips trailing zeros. + +## 4. TEST VECTORS — see test/utils/*.test.ts, test/solana/*.test.ts, test/actions/*.test.ts, fixtures sealevel-transfer-remote.json (mainnet tx cWFKiumuvVuvrxM8xtunZxNM4FNUppSdyNm7HEqKjV3ZmENebD4DAf44kbyvq9fKJ61VzNrH3tYpLJgUrY8MEGW, instruction base64 AQEBAQEBAQEBb2VsYRw0lpkefGEc7V7lzQze6WnFPvyKVJeuBQgZse8A7SkSACqpcJ0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, amount 676200000000, recipient aleo1rs6fdxg703s3em27uhxsehhfd8znaly22jt6upggrxc77q8d9yfq33pk28), sealevel-igp-account.json (JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M). Solana rent vectors 141→1_872_240, 194→2_241_120, 0→890_880; fee 10_000. +Live gates: BRIDGE_LIVE_FUNDS=1 + BRIDGE_LIVE_STATE_DIR; BRIDGE_LIVE_MAINNET_ACK=I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS + BRIDGE_LIVE_MAINNET_CASES; BRIDGE_LIVE_MAINNET_EXECUTE=I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS. Hyperlane minimum = one atomic unit; xReserve = 2 USDC. + +## 5. AGENT/MCP — three read-only tools: bridge_list_assets, bridge_list_routes, bridge_quote_transfer. No execute tool. + +## 6. EXTERNAL — Circle attestation mainnet/testnet URLs above; DEFAULT_SOLANA_RPC_URL https://api.mainnet-beta.solana.com; Hyperlane explorer GraphQL https://explorer4.hasura.app/v1/graphql (live tests only). + +## 7. INVARIANTS +1. Re-resolve route from live registry by id; refuse if plan.registryVersion != registry.version. +2. Checkpoint is an allowlist. +3. Idempotent Aleo rebroadcast (prove → persist bytes → broadcast; duplicate = success). +4. Exact integer arithmetic (hook payment, IGP) — on-chain equality asserts. +5. Timeout != failure. +6. getStatus checks destination nullifier first for inbound xReserve. +7. Private xReserve deposit recipient = wrapper program's address. +8. xReserveHexToAleoBytes has no spaces; struct/array literals elsewhere do. diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml new file mode 100644 index 0000000..16ebf33 --- /dev/null +++ b/bridge-sdk/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "aleo-bridge-sdk" +version = "0.1.0" +description = "Python SDK for bridging assets between Aleo, Ethereum and Solana over Hyperlane warp routes and Circle xReserve" +readme = "README.md" +requires-python = ">=3.10" +# PyNaCl is NOT optional: every Aleo-side execution goes through delegated proving +# (`proving="delegate"`, the default), and the proving request is sealed with a NaCl box before it +# leaves the machine — without it `complete`/`execute` raise ImportError at the last step, after the +# source transaction is already on chain. `aleo-sdk[dps]` pulls the same package. +dependencies = ["aleo-sdk>=0.5.0", "requests>=2", "pynacl>=1.5"] + +[project.optional-dependencies] +evm = ["web3>=7,<9", "eth-account>=0.13"] +solana = ["solders>=0.21", "solana>=0.35"] +# mcp 2.0 renamed the Server registration API — pin to 1.x (same rule as shield-swap). +mcp = ["mcp>=1.0,<2"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "web3>=7,<9", "eth-account>=0.13", + "solders>=0.21", "solana>=0.35", "mcp>=1.0,<2", "pynacl>=1.5"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python/aleo_bridge"] diff --git a/bridge-sdk/pyrightconfig.json b/bridge-sdk/pyrightconfig.json new file mode 100644 index 0000000..973333f --- /dev/null +++ b/bridge-sdk/pyrightconfig.json @@ -0,0 +1,7 @@ +{ + "include": ["python/aleo_bridge"], + "extraPaths": ["python"], + "venvPath": ".", + "venv": ".venv", + "reportMissingModuleSource": false +} diff --git a/bridge-sdk/pytest.ini b/bridge-sdk/pytest.ini new file mode 100644 index 0000000..3204b8a --- /dev/null +++ b/bridge-sdk/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +pythonpath = python +testpaths = tests +markers = + live: read-only tests against the REAL mainnet API (BRIDGE_LIVE_READS=1 and -m live) +addopts = -m "not live" +asyncio_mode = auto diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md new file mode 100644 index 0000000..93291cb --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -0,0 +1,331 @@ +# aleo-bridge — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client that moves assets between Aleo, Ethereum and Solana +over the reviewed Hyperlane warp routes and Circle xReserve deployments +(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`). +MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as +tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas. +Registry version `2026-08-31.solana-deposits.1`. + +## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked) + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +``` + +### `from_env(**overrides: 'Any') -> "'Bridge'"` + +Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints. + +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None) -> "'Bridge'"` + +The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when +creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. + +### `status(self) -> 'BridgeStatus'` + +Read-only re-orientation: addresses and public balances of every registry asset per configured chain. +Plan 4 fills ``pending`` from the checkpoint store. + +### `quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, protocol: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` + +Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + +``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` +tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of +``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is +the destination-chain address. ``mint_mode`` (xReserve into Aleo only): +``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` +— you finish it yourself with ``complete`` and must keep ``secret_nonce``. +Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / +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)` + +Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + +Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to +``on_checkpoint`` (and the bound store) at every boundary — including +AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is +resumable without proving twice. ``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 +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. + +### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` + +Poll until the transfer finishes or needs you: stops at ``progress.next`` +in resume / complete / done / failed, or at any status in ``until``. + +A ``PollingTimeoutError`` is NOT a failure — the transfer is still in +flight; call ``wait`` again or ``recover`` later. ``on_update`` receives +each changed ``Progress``. A transient error (flaky RPC/HTTP transport) +is retried up to ``max_consecutive_errors`` times, calling ``on_error`` +on each tolerated retry; a non-transient error propagates immediately. + +### `recover(self, checkpoint)` + +Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + +Re-resolves the route from the live registry and reads chain state once; +``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, +``done`` or ``failed``. + +### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` + +Finish an interrupted source submission (``progress.next == "resume"``). + +Rebroadcasts the identical proved Aleo transaction (a duplicate response is +success) or, on EVM, re-scans history and only then authorizes the single +missing deposit/dispatch. Never repeats a confirmed step. + +### `complete(self, progress, *, secret_nonce: 'str', on_checkpoint=None, proving: 'str' = 'delegate')` + +Submit the private USDCx mint (``progress.next == "complete"``). + +Requires the same ``secret_nonce`` given to ``execute``; the SDK never +stored it. Submits exactly one ``private_mint`` and returns +``DESTINATION_CONFIRMING`` progress to ``wait`` on. + +### `pending(self) -> 'list'` + +The in-flight transfers of this profile — every checkpoint in the bound store, +reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one +unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` +with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` +on any entry to refresh it against live chain state. + +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. + + +## Tier 2 — the protocol modules (building your own flows) + +Every Aleo write returns an `AleoCall`: nothing touches the network until +`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not +broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local +proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana +writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`. +The lifecycle verbs above compose these; use them directly only when you +need a single leg. Confirm-gated writes and the never-resend rule above +apply here too — these are the same broadcasts, just one leg at a time. + +### `hyperlane.transfer_remote(self, asset: 'Any', recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, as_signer: 'bool' = False, gas_payment_microcredits: 'int | None' = None) -> 'AleoCall[DispatchReceipt]'` + +Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the +lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again. + +### `hyperlane.quote_gas_payment(self, asset: 'Any') -> 'GasQuote'` + +Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. + +### `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) -> 'AleoCall[BurnReceipt]'` + +Burn USDCx for USDC on Ethereum. ``private`` (default) 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. + +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` + +Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). + +### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` + +One Circle request for *message_hash*; ``None`` while pending (404). + +### `shield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `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]'` + + + +### `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. + +### `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]'` + +Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + +Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the +quoted token amount only when the allowance is short (USDT: a non-zero allowance is +reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes +send the fee only. Each hash is checkpointed before polling; a timeout returns a +pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. + +### `eth.deposit_usdc(self, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mint_mode: 'str | None' = None, secret_nonce: 'str' = '0scalar', plan: 'Plan | None' = None) -> 'EvmCall[DepositReceipt]'` + +Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + +``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private +record), or ``private`` (deposit addressed to the shielded wrapper program; you must later +run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, +which the SDK never stores). Approves exactly the amount only when the allowance is +short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` +carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to +``plan.mint_mode`` when a plan is given, else ``"public"``. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. ``secret_nonce`` is never +part of a plan, so a private deposit must still pass the same one it was quoted with. + +### `eth.quote_transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, route: 'Route | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'EvmHyperlaneQuote'` + +Quote an Ethereum → Aleo Hyperlane transfer without signing. + +Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so +``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): +``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 +allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + +``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and +amount, and is validated against the live registry. It is mutually exclusive with +``asset=``/``route=``/``sender=``. + +### `sol.transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'SolCall[DispatchReceipt]'` + +Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + +Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, +``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + +``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for +the connected wallet; its registry version and route id are re-checked against the live registry +when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a +``ValueError``. Without a plan, ``recipient`` is required. + +### `sol.quote_transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'SolanaHyperlaneQuote'` + +Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind +``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required +for the fee estimate. + +``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry +version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ +``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so +re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + +### Routes in the pinned registry + +| Route id | Protocol | Environment | Availability | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->ethereum/usdc` | xreserve | mainnet | active | +| `xreserve:sepolia/usdc->aleo-testnet/usdcx` | xreserve | testnet | active | +| `xreserve:aleo-testnet/usdcx->sepolia/usdc` | xreserve | testnet | active | +| `hyperlane:ethereum/eth->aleo/eth` | hyperlane | mainnet | active | +| `hyperlane:aleo/eth->ethereum/eth` | hyperlane | mainnet | active | +| `hyperlane:ethereum/wbtc->aleo/wbtc` | hyperlane | mainnet | active | +| `hyperlane:aleo/wbtc->ethereum/wbtc` | hyperlane | mainnet | active | +| `hyperlane:ethereum/usdt->aleo/usdt` | hyperlane | mainnet | active | +| `hyperlane:aleo/usdt->ethereum/usdt` | hyperlane | mainnet | active | +| `hyperlane:solana/sol->aleo/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/sol->solana/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/aleo->ethereum/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->solana/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:solana/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->base/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:base/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->hyperevm/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:hyperevm/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/usad->aleo/usad` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/usad->ethereum/usad` | hyperlane | mainnet | metadata-required | + +`metadata-required` routes are listed but refused by `quote`/`execute` +until their deployments are reviewed upstream. + diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py new file mode 100644 index 0000000..7d2aabb --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -0,0 +1,78 @@ +"""aleo_bridge — move assets between Aleo, Ethereum and Solana (Hyperlane warp routes, Circle xReserve). + +Web3.py idioms: bind an ``aleo.Aleo`` facade to :class:`Bridge`; reads return values, Aleo writes +return an :class:`AleoCall` with ``simulate()`` / ``prove()`` / ``transact()`` / ``delegate()``. +Exports grow in Tasks 4–11 of plan 1; keep this list sorted. +""" +from __future__ import annotations + +from pathlib import Path + +__version__ = "0.1.0" + +from .errors import ( # noqa: E402 + AmbiguousRouteError, AttestationError, BridgeError, ChainMismatchError, CheckpointInvalidError, + ConfigurationError, DeliveryUnknownError, InsufficientBalanceError, InvalidAmountError, + InvalidRecipientError, MissingExtraError, NotResumableError, PollingTimeoutError, + RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, +) +from .registry import DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, validate_registry # noqa: E402 +from .types import ( # noqa: E402 + CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, BridgeStatus, BurnReceipt, + ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, GasQuote, + MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, + to_progress, +) +from ._calls import AleoCall, EvmCall, SolCall # noqa: E402 +from .checkpoint import Checkpoint, CheckpointStore, FileCheckpointStore, create_checkpoint # noqa: E402 +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 .hyperlane import HyperlaneModule # noqa: E402 +from . import lifecycle # noqa: E402 +from .lifecycle import prepare # noqa: E402 +from .agent import bridge_tools, dispatch_tool # noqa: E402 +from .privacy import PrivacyModule # noqa: E402 +from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 +from .sol import DEFAULT_SOLANA_RPC_URL, Solana, SolModule # noqa: E402 +from .xreserve import XReserveModule # noqa: E402 + +__all__ = [ + "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", + "CheckpointInvalidError", "ConfigurationError", "DeliveryUnknownError", "InsufficientBalanceError", + "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", + "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", + "UnsupportedRouteError", + "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", + "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", + "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", + "EthModule", "Ethereum", "EvmCall", + "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", + "lifecycle", "prepare", + "agent_guide", "bridge_tools", "dispatch_tool", +] + +AGENTS_FILE = "AGENTS.md" + + +def agent_guide() -> str: + """The packaged agent guide: the prose a model reads alongside :func:`bridge_tools`. + + ``AGENTS.md`` is generated from the registry and the lifecycle docstrings by + ``codegen/gen_context.py`` and shipped inside the wheel. When it is not present (a source + checkout before that step has run), this returns a short pointer instead of failing — the tool + definitions themselves always carry their own descriptions. + """ + path = Path(__file__).with_name(AGENTS_FILE) + try: + return path.read_text(encoding="utf-8") + except OSError: + return (f"aleo_bridge {__version__}: no packaged {AGENTS_FILE} in this build. Generate it with " + "codegen/gen_context.py, or call aleo_bridge.bridge_tools() — every tool carries its " + "own description and JSON schema.") diff --git a/bridge-sdk/python/aleo_bridge/__main__.py b/bridge-sdk/python/aleo_bridge/__main__.py new file mode 100644 index 0000000..9b845b0 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/__main__.py @@ -0,0 +1,36 @@ +"""``python -m aleo_bridge`` prints the agent guide; ``[status|routes|assets]`` run those checks +(``status`` needs BRIDGE_PRIVATE_KEY, read-only).""" +from __future__ import annotations + +import dataclasses +import json +import sys + +from .registry import DEFAULT_REGISTRY + +USAGE = "usage: python -m aleo_bridge [status|routes|assets]" + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + from . import agent_guide + print(agent_guide(), end="") + return 0 + command = args[0] + if command == "routes": + print(json.dumps([r.id for r in DEFAULT_REGISTRY.routes(include_unavailable=True)], indent=1)) + return 0 + if command == "assets": + print(json.dumps([a.id for a in DEFAULT_REGISTRY.assets()], indent=1)) + return 0 + if command == "status": + from .client import Bridge + print(json.dumps(dataclasses.asdict(Bridge.from_env().status()), indent=1, default=str)) + return 0 + print(USAGE, file=sys.stderr) + return 2 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/bridge-sdk/python/aleo_bridge/_base58.py b/bridge-sdk/python/aleo_bridge/_base58.py new file mode 100644 index 0000000..3d89212 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_base58.py @@ -0,0 +1,35 @@ +"""Bitcoin/Solana base58 (no checksum). Vendored so Solana recipients encode without the solana extra.""" +from __future__ import annotations + +ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +_INDEX = {c: i for i, c in enumerate(ALPHABET)} + + +def b58decode(s: str) -> bytes: + """Decode *s*; leading ``1`` characters become leading zero bytes. ``ValueError`` on a bad character.""" + if not isinstance(s, str): + raise ValueError("base58 input must be a str") + n = 0 + for ch in s: + try: + n = n * 58 + _INDEX[ch] + except KeyError: + raise ValueError(f"Invalid base58 character {ch!r}") from None + body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" + pad = len(s) - len(s.lstrip("1")) + return bytes(pad) + body + + +def b58encode(data: bytes) -> str: + """Encode *data*; leading zero bytes become leading ``1`` characters.""" + data = bytes(data) + pad = len(data) - len(data.lstrip(b"\x00")) + n = int.from_bytes(data, "big") + out = [] + while n: + n, rem = divmod(n, 58) + out.append(ALPHABET[rem]) + return "1" * pad + "".join(reversed(out)) + + +__all__ = ["ALPHABET", "b58decode", "b58encode"] diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py new file mode 100644 index 0000000..ade2dc7 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -0,0 +1,418 @@ +"""AleoCall — a facade BoundCall plus a typed result builder (contract §_calls.py). + +The caller picks the proving path; every path harvests the ROOT transition's outputs (the last +transition whose program/function match the call — Aleo orders child transitions first) so the typed +result is complete without waiting for confirmation. ``delegate(broadcast=False)`` is the +checkpointable path: prove on the DPS, hand back the serialized transaction, then ``submit_prepared`` +— where a duplicate-transaction response counts as success (invariant 3). +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Callable, Generic, TypeVar + +from .errors import BridgeError, ConfigurationError +from .types import PreparedTx + +R = TypeVar("R") +_DUPLICATE_MARKER = "already exists" + + +def _network_module(aleo: Any) -> Any: + import aleo as aleo_pkg + return getattr(aleo_pkg, aleo.network_name) + + +def extract_tx_id(payload: Any) -> str: + """Transaction id from a DPS result payload (dict variants or a bare id string).""" + if isinstance(payload, str) and payload.strip(): + return payload.strip() + if isinstance(payload, dict): + tx = payload.get("transaction") + if isinstance(tx, dict) and tx.get("id"): + return str(tx["id"]) + for key in ("transaction_id", "transactionId", "id", "txid", "tx_id"): + if payload.get(key): + return str(payload[key]) + raise ValueError(f"Cannot find a transaction id in DPS payload: {payload!r}") + + +def payload_transitions(payload: Any) -> "list[dict[str, Any]] | None": + """Decoded transitions from a DPS payload that carries the whole transaction; None when it is id-only.""" + if not isinstance(payload, dict): + return None + return _transaction_transitions(payload.get("transaction")) + + +def _transaction_transitions(tx: Any) -> "list[dict[str, Any]] | None": + if not isinstance(tx, dict): + return None + transitions = (tx.get("execution") or {}).get("transitions") + if not isinstance(transitions, list): + return None + return [{"program": str(t.get("program")), "function": str(t.get("function")), "outputs": t.get("outputs", [])} + for t in transitions] + + +def output_values(outputs: Any) -> list[str]: + values: list[str] = [] + for out in outputs: + if isinstance(out, dict): + value = out.get("value") + values.append(value if isinstance(value, str) else str(value)) + else: + values.append(str(out)) + return values + + +def root_outputs(decoded: list[dict[str, Any]], program: str, function: str) -> list[str]: + """Output values of the LAST transition matching *program*/*function* (the root).""" + for entry in reversed(decoded): + if str(entry.get("program")) == program and str(entry.get("function")) == function: + return output_values(entry.get("outputs", [])) + return [] + + +def is_duplicate_submission(exc: BaseException) -> bool: + """True only for a node's "already exists" rejection (an idempotent rebroadcast). + + Deliberately narrow: a message like "duplicate serial number" or "duplicate output id" is a + REAL double-spend failure and must propagate, not be swallowed as success. + """ + return _DUPLICATE_MARKER in str(exc).lower() + + +class AleoCall(Generic[R]): + """A prepared Aleo write. Nothing touches the network until a verb runs.""" + + def __init__(self, aleo: Any, bound: Any, build_result: Callable[[str, list[str]], R], *, + imports: "dict[str, str] | None" = None) -> None: + self._aleo = aleo + self._bound = bound + self._build = build_result + self._imports = dict(imports or {}) + self._imports_registered = False + + def __repr__(self) -> str: + # Never print .inputs here: a record plaintext (private_burn arg 0) or a secret nonce + # (private_mint arg 3) can be an input literal, and repr() output tends to end up in logs. + return f"AleoCall({self.program_id}/{self.function_name}, inputs={len(self.inputs)} literals)" + + @property + def program_id(self) -> str: + return str(self._bound.program_id) + + @property + def function_name(self) -> str: + return str(self._bound.function_name) + + @property + def inputs(self) -> list[str]: + """The exact Aleo input literals as they will be submitted.""" + return list(self._bound.args) + + # ── import registration (program sources the process must know before authorizing) ── + def _register_imports(self) -> None: + if self._imports_registered or not self._imports: + return + process = self._aleo.process + net = _network_module(self._aleo) + for program_id, source in self._imports.items(): + if process.contains_program(net.ProgramID.from_string(program_id)): + continue + process.add_program(net.Program.from_source(source)) + self._imports_registered = True + + # ── verbs ── + def simulate(self, account: Any = None) -> Any: + """Local authorization — no proof, no network send; inspect outputs before spending.""" + self._register_imports() + return self._bound.simulate(account) + + def prove(self, account: Any = None, **fee: Any) -> PreparedTx: + """Prove locally, do NOT broadcast; returns the serialized transaction for checkpointing.""" + self._register_imports() + tx = self._bound.build_transaction(account, **fee) + return PreparedTx(transaction_id=str(tx.id), serialized=str(tx.raw)) + + def transact(self, account: Any = None, **fee: Any) -> R: + """Prove locally, harvest root outputs, broadcast, build the typed result.""" + self._register_imports() + tx = self._bound.build_transaction(account, **fee) + outputs = root_outputs(tx.decoded(), self.program_id, self.function_name) + self._aleo.network.submit_transaction(tx.raw) + return self._build(str(tx.id), outputs) + + def delegate(self, account: Any = None, *, wait: bool = True, wait_timeout: float = 180.0, + broadcast: bool = True, **fee: Any) -> R: + """Delegate proving to the DPS (fee master pays by default). + + ``broadcast=True``: the prover broadcasts; outputs come from the returned transaction, or after + waiting and fetching when the payload is id-only. ``broadcast=False``: ``delegate_prepared`` then + ``submit_prepared`` so the exact bytes exist locally before the network sees them. + + When the payload is id-only, ``wait`` is effectively forced ``True`` regardless of what was + passed: the transaction body must be fetched after confirmation to harvest outputs, so a wait + happens either way in that branch. + + If ``wait`` (or the forced wait above) times out, ``aleo.facade.errors.TransactionConfirmationTimeout`` + propagates AFTER the transaction has already been broadcast by the DPS — the transaction id is + recoverable from the exception's ``tx_id`` attribute (or from re-deriving it) for later polling; + the transfer itself was not rolled back. + """ + if not broadcast: + return self.submit_prepared(self.delegate_prepared(account, **fee), wait=wait, wait_timeout=wait_timeout) + self._register_imports() + payload = self._bound.delegate(account, broadcast=True, **fee) + tx_id = extract_tx_id(payload) + decoded = payload_transitions(payload) + if decoded is None: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + tx = self._aleo.network.get_transaction_object(tx_id) + decoded = [{"program": str(t.program_id), "function": str(t.function_name), "outputs": list(t.outputs())} + for t in tx.transitions()] + elif wait: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + return self._build(tx_id, root_outputs(decoded, self.program_id, self.function_name)) + + def delegate_prepared(self, account: Any = None, **fee: Any) -> PreparedTx: + """DPS proves with ``broadcast=False``; returns the serialized transaction for checkpointing.""" + self._register_imports() + payload = self._bound.delegate(account, broadcast=False, **fee) + tx = payload.get("transaction") if isinstance(payload, dict) else None + if not isinstance(tx, dict) or not tx.get("id"): + raise ConfigurationError( + "The delegated prover did not return the transaction body; cannot checkpoint an unbroadcast " + "transaction. Use delegate(broadcast=True) or prove() instead.") + return PreparedTx(transaction_id=str(tx["id"]), serialized=json.dumps(tx)) + + def submit_prepared(self, prepared: PreparedTx, *, wait: bool = True, wait_timeout: float = 180.0) -> R: + """Broadcast a prepared transaction; a duplicate-transaction rejection is success (idempotent rebroadcast). + + If ``wait`` is true and confirmation does not land within ``wait_timeout``, + ``aleo.facade.errors.TransactionConfirmationTimeout`` propagates AFTER the transaction has already + been broadcast (the ``submit_transaction`` call above already returned/succeeded) — this is not a + submission failure. The transaction id is recoverable from ``prepared.transaction_id`` or from the + exception's own ``tx_id`` attribute, for later polling or a checkpoint. Keeping this raise (rather + than swallowing it) is consistent with the rest of the facade; the lifecycle layer calls + ``submit_prepared(wait=False)`` and does its own status polling instead of relying on this wait. + """ + try: + self._aleo.network.submit_transaction(prepared.serialized) + except Exception as exc: # noqa: BLE001 — the node's error type varies by transport + if not is_duplicate_submission(exc): + raise + if wait: + self._aleo.network.wait_for_transaction(prepared.transaction_id, timeout=wait_timeout) + try: + decoded = _transaction_transitions(json.loads(prepared.serialized)) or [] + except (TypeError, ValueError): + decoded = [] + return self._build(prepared.transaction_id, root_outputs(decoded, self.program_id, self.function_name)) + + +@dataclass(frozen=True) +class EvmStep: + """One unsigned EVM transaction the call will broadcast, in order.""" + + kind: str # "approve" | "main" + to: str + data: str # 0x calldata + value: int = 0 # wei (msg.value) + + +@dataclass(frozen=True) +class EvmOutcome: + """What the step runner observed; the module's ``finish`` turns it into the typed result.""" + + status: str # "SOURCE_APPROVAL_PENDING" | "SOURCE_CONFIRMING" | "CONFIRMED" + sender: str + approval_tx_ids: tuple[str, ...] + source_tx_id: str | None + receipt: Any | None # web3 receipt when status == "CONFIRMED" + + +def _assert_evm_success(receipt: Any, tx_hash: str) -> None: + if int(receipt["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {tx_hash}") + + +class EvmCall(Generic[R]): + """A prepared Ethereum write: ``build()`` for unsigned transaction dicts, ``send()`` to broadcast. + + ``steps(sender)`` is evaluated at ``build``/``send`` time so allowances and router + fees are read at the last responsible moment. ``send`` broadcasts approvals then the + main call, emits a ``Checkpoint`` after every broadcast (before polling) and after + confirmation, and returns a pending result when a receipt does not arrive within + ``timeout_seconds`` — a timeout is not a failure. + """ + + def __init__(self, conn: Any, *, plan: "Plan", registry: "Registry", + steps: Callable[[str], list[EvmStep]], finish: Callable[[EvmOutcome], R], + store: "CheckpointStore | None" = None) -> None: + self._conn, self.plan, self._registry = conn, plan, registry + self._steps, self._finish, self._store = steps, finish, store + self._broadcast_id: str | None = None + + def _record_broadcast(self, tx_hash: str) -> None: + """Arm the single-use guard with the FIRST hash this call put on the wire.""" + if self._broadcast_id is None: + self._broadcast_id = tx_hash + + def _refuse_a_second_send(self) -> None: + if self._broadcast_id is not None: + raise BridgeError( + f"this call already broadcast {self._broadcast_id}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") + + def _sender(self) -> str: + sender = self._conn.require_address() + if self.plan.sender: + Web3 = self._conn.w3.__class__ + if Web3.to_checksum_address(self.plan.sender) != sender: + raise ConfigurationError( + f"Prepared sender {self.plan.sender} does not match connected account {sender}") + return sender + + def build(self) -> list[dict]: + """Unsigned transaction dicts in submission order (approvals then main). Reads only. + + With an account configured the plan's sender must be that account (same rule ``send()`` + applies), so a mismatched plan fails here rather than producing calldata nobody can sign. + """ + sender = self._sender() if self._conn.address is not None else self.plan.sender + if sender is None: + raise ConfigurationError("build() needs a sender: configure a signer or set plan.sender") + nonce = int(self._conn.w3.eth.get_transaction_count(sender, "pending")) + return [{"from": sender, "to": step.to, "data": step.data, "value": step.value, + "chainId": self._conn.chain_id, "nonce": nonce + i} + for i, step in enumerate(self._steps(sender))] + + def _checkpoint(self, result: R, on_checkpoint: Callable[["Checkpoint"], None] | None, tx_hash: str) -> None: + """Emit the checkpoint for a just-broadcast *tx_hash* to the caller first, then the store. + + The caller's callback runs before the store because the transaction is already on the wire: + if persistence fails, the hash must still have reached the one channel that can act on it. + A store failure is then fatal and names the hash — losing it silently would strand funds. + """ + from .checkpoint import create_checkpoint + + checkpoint = create_checkpoint(self.plan, result.receipt, self._registry) # type: ignore[attr-defined] + if on_checkpoint is not None: + on_checkpoint(checkpoint) # the caller's own callback: errors are theirs + if self._store is not None: + try: + self._store.save(checkpoint) + except Exception as exc: # noqa: BLE001 — any store backend failure + raise BridgeError( + f"Transaction {tx_hash} WAS broadcast but its checkpoint {checkpoint.id} could not be saved " + f"({exc}); record the transaction hash before retrying — resending would double-spend") from exc + + def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, + on_checkpoint: Callable[["Checkpoint"], None] | None = None) -> R: + """Broadcast every step in order; checkpoint each hash before polling; pending on timeout. + + ``wait=False`` broadcasts only the first step and returns its pending result; call + ``bridge.eth.source_status`` (or plan 4's ``resume``) to continue. + + A call is single-use once ANY of its steps has broadcast: a second ``send()`` raises rather + than re-approving and re-dispatching the same funds. The approval and main broadcasts of one + ``send()`` are of course fine, and a failure before the first broadcast (a validation error, a + failed read) leaves the call usable. ``build()`` stays repeatable — it spends nothing. + """ + self._refuse_a_second_send() + sender = self._sender() + approvals: list[str] = [] + for step in self._steps(sender): + try: + tx_hash = self._conn.send_transaction({"from": sender, "to": step.to, "data": step.data, "value": step.value}) + except BridgeError as exc: + # A send whose RPC response was lost may still have reached the node — arm the guard + # with the hash it named, so a retry cannot turn an ambiguous send into a double spend. + lost = getattr(exc, "broadcast_id", None) + if lost is not None: + self._record_broadcast(str(lost)) + raise + self._record_broadcast(tx_hash) + if step.kind == "approve": + approvals.append(tx_hash) + pending = self._finish(EvmOutcome("SOURCE_APPROVAL_PENDING", sender, tuple(approvals), None, None)) + self._checkpoint(pending, on_checkpoint, tx_hash) + if not wait: + return pending + receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + if receipt is None: + return pending + _assert_evm_success(receipt, tx_hash) + continue + pending = self._finish(EvmOutcome("SOURCE_CONFIRMING", sender, tuple(approvals), tx_hash, None)) + self._checkpoint(pending, on_checkpoint, tx_hash) + if not wait: + return pending + receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + if receipt is None: + return pending + _assert_evm_success(receipt, tx_hash) + confirmed = self._finish(EvmOutcome("CONFIRMED", sender, tuple(approvals), tx_hash, receipt)) + self._checkpoint(confirmed, on_checkpoint, tx_hash) + return confirmed + raise BridgeError("EvmCall has no main step") + + +class SolCall(Generic[R]): + """A prepared Solana-origin call (spec §7). + + ``build()`` re-quotes, compiles the v0 transaction and signs it with the ephemeral + unique-message keypair only — a preview that spends nothing. ``send()`` rebuilds with a + fresh quote, unique key and blockhash, checks the wallet balance, adds the fee-payer + signature through the connection's signer, broadcasts, hands the ``Checkpoint`` built from the + SOURCE_CONFIRMING receipt to ``on_checkpoint`` and then to the bound store — both before the + first confirmation poll — and returns the typed result. A polling timeout is not a failure: + the pending receipt comes back with the signature and blockhash lifetime. + """ + + def __init__(self, module: Any, *, route: Any, recipient: str, amount_atomic: int, plan: Any, + build_result: Callable[[Any], R], store: "CheckpointStore | None" = None) -> None: + self._module = module + self.route = route + self.recipient = recipient + self.amount_atomic = amount_atomic + self.plan = plan + self._build_result = build_result + self._store = store + self.quote: Any = None + self._built: Any = None + self._broadcast_id: str | None = None + + def _record_broadcast(self, signature: str) -> None: + """Arm the single-use guard with the signature this call put on the wire (or may have).""" + if self._broadcast_id is None: + self._broadcast_id = signature + + def build(self) -> Any: + """Partially signed ``VersionedTransaction`` (unique-message signer only); sets ``self.quote``.""" + self._built = self._module._build_transaction(recipient=self.recipient, + amount_atomic=self.amount_atomic, plan=self.plan) + self.quote = self._built.quote + return self._built.transaction + + def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, + on_checkpoint: Callable[[Any], None] | None = None) -> R: + """Broadcast once. A call is single-use from the moment its transaction reaches the node (or + may have, when the RPC response was lost): a second ``send()`` raises rather than signing a + second transfer of the same funds. A failure before the broadcast — a stale plan, an + insufficient balance — leaves the call usable, and ``build()`` stays repeatable.""" + if self._broadcast_id is not None: + raise BridgeError( + f"this call already broadcast {self._broadcast_id}; use bridge.sol.source_status(plan, receipt) " + "to follow it — do not resend") + self.build() + receipt = self._module._submit(self._built, wait=wait, timeout_seconds=timeout_seconds, + poll_seconds=poll_seconds, on_checkpoint=on_checkpoint, + store=self._store, on_broadcast=self._record_broadcast) + return self._build_result(receipt) + + +__all__ = ["AleoCall", "EvmCall", "EvmOutcome", "EvmStep", "SolCall", "extract_tx_id", "is_duplicate_submission", + "output_values", "payload_transitions", "root_outputs"] diff --git a/bridge-sdk/python/aleo_bridge/_evm_abi.py b/bridge-sdk/python/aleo_bridge/_evm_abi.py new file mode 100644 index 0000000..5fdb7fc --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_evm_abi.py @@ -0,0 +1,60 @@ +"""Minimal ABI fragments for the reviewed Ethereum deployments (brief §3.1, §3.2, §3.7). Pure data.""" +from __future__ import annotations + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +# The single EVM chain the bridge drives per environment (base/hyperevm carry only metadata-required routes). +EVM_CHAIN_BY_ENVIRONMENT = {"mainnet": "ethereum", "testnet": "sepolia"} + +ERC20_ABI = [ + {"type": "function", "name": "balanceOf", "stateMutability": "view", + "inputs": [{"name": "owner", "type": "address"}], "outputs": [{"name": "", "type": "uint256"}]}, + {"type": "function", "name": "allowance", "stateMutability": "view", + "inputs": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}]}, + {"type": "function", "name": "approve", "stateMutability": "nonpayable", + "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "", "type": "bool"}]}, +] + +WARP_ROUTE_ABI = [ + {"type": "function", "name": "quoteTransferRemote", "stateMutability": "view", + "inputs": [{"name": "destination", "type": "uint32"}, {"name": "recipient", "type": "bytes32"}, + {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "quotes", "type": "tuple[]", + "components": [{"name": "token", "type": "address"}, {"name": "amount", "type": "uint256"}]}]}, + {"type": "function", "name": "transferRemote", "stateMutability": "payable", + "inputs": [{"name": "destination", "type": "uint32"}, {"name": "recipient", "type": "bytes32"}, + {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "messageId", "type": "bytes32"}]}, + {"type": "event", "name": "SentTransferRemote", "anonymous": False, + "inputs": [{"name": "destination", "type": "uint32", "indexed": True}, + {"name": "recipient", "type": "bytes32", "indexed": True}, + {"name": "amount", "type": "uint256", "indexed": False}]}, +] + +XRESERVE_ABI = [ + {"type": "function", "name": "depositToRemote", "stateMutability": "nonpayable", + "inputs": [{"name": "value", "type": "uint256"}, {"name": "remoteDomain", "type": "uint32"}, + {"name": "remoteRecipient", "type": "bytes32"}, {"name": "localToken", "type": "address"}, + {"name": "maxFee", "type": "uint256"}, {"name": "hookData", "type": "bytes"}], + "outputs": []}, + {"type": "event", "name": "DepositedToRemote", "anonymous": False, + "inputs": [{"name": "localToken", "type": "address", "indexed": True}, + {"name": "value", "type": "uint256", "indexed": False}, + {"name": "localDepositor", "type": "address", "indexed": True}, + {"name": "remoteRecipient", "type": "bytes32", "indexed": True}, + {"name": "remoteDomain", "type": "uint32", "indexed": False}, + {"name": "remoteToken", "type": "bytes32", "indexed": False}, + {"name": "maxFee", "type": "uint256", "indexed": False}, + {"name": "hookData", "type": "bytes", "indexed": False}]}, +] + +MAILBOX_ABI = [ + {"type": "event", "name": "DispatchId", "anonymous": False, + "inputs": [{"name": "messageId", "type": "bytes32", "indexed": True}]}, + {"type": "function", "name": "delivered", "stateMutability": "view", + "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"name": "", "type": "bool"}]}, +] + +__all__ = ["ERC20_ABI", "EVM_CHAIN_BY_ENVIRONMENT", "MAILBOX_ABI", "WARP_ROUTE_ABI", "XRESERVE_ABI", "ZERO_ADDRESS"] diff --git a/bridge-sdk/python/aleo_bridge/_keccak.py b/bridge-sdk/python/aleo_bridge/_keccak.py new file mode 100644 index 0000000..41bf250 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_keccak.py @@ -0,0 +1,57 @@ +"""Pure-Python Keccak-256 (Ethereum's keccak; padding byte 0x01, NOT SHA3-256's 0x06). + +Vendored so an Aleo-only install can hash xReserve payloads, verify Circle attestations and +checksum EVM addresses without web3. When web3 is installed the two agree (tested). +""" +from __future__ import annotations + +_RC = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +] +_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]] +_MASK = (1 << 64) - 1 +_RATE = 136 # 1088-bit rate for a 256-bit digest + + +def _rol(x: int, n: int) -> int: + n %= 64 + return ((x << n) | (x >> (64 - n))) & _MASK if n else x + + +def _keccak_f(a: list[int]) -> None: + for rc in _RC: + c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)] + d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)] + for i in range(25): + a[i] ^= d[i % 5] + b = [0] * 25 + for x in range(5): + for y in range(5): + b[y + 5 * ((2 * x + 3 * y) % 5)] = _rol(a[x + 5 * y], _ROT[x][y]) + for x in range(5): + for y in range(5): + a[x + 5 * y] = b[x + 5 * y] ^ ((~b[(x + 1) % 5 + 5 * y]) & b[(x + 2) % 5 + 5 * y]) + a[0] ^= rc + + +def keccak256(data: "bytes | bytearray | memoryview") -> bytes: + """Keccak-256 digest of *data* (32 bytes).""" + state = [0] * 25 + msg = bytearray(data) + b"\x01" + msg += bytes((-len(msg)) % _RATE) + msg[-1] |= 0x80 + for off in range(0, len(msg), _RATE): + block = msg[off:off + _RATE] + for i in range(_RATE // 8): + state[i] ^= int.from_bytes(block[8 * i:8 * i + 8], "little") + _keccak_f(state) + return b"".join(state[i].to_bytes(8, "little") for i in range(4)) + + +__all__ = ["keccak256"] diff --git a/bridge-sdk/python/aleo_bridge/_plan.py b/bridge-sdk/python/aleo_bridge/_plan.py new file mode 100644 index 0000000..98b4610 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_plan.py @@ -0,0 +1,68 @@ +"""``build_plan`` — the one Plan builder every origin chain shares (brief §2.1, mirrors veil ``prepare``). + +Plan 4's ``lifecycle.prepare`` is the Tier-1 entry point; this helper is the Tier-2 path, so +``bridge.eth.*`` / ``bridge.sol.*`` calls carry a checkpointable plan without importing lifecycle. +It lives outside ``eth.py`` because the step shape is protocol- and chain-driven, not Ethereum-driven: +the wallet steps' executor comes from the source chain's family, so the same builder describes an +Ethereum, Solana or Aleo origin. +""" +from __future__ import annotations + +from .errors import BridgeError, UnsupportedRouteError +from .registry import Asset, Registry, Route +from .types import Plan, Step +from .units import format_decimal_amount + +WALLET_EXECUTOR_BY_FAMILY = {"evm": "evm-wallet", "solana": "solana-wallet", "aleo": "aleo-wallet"} + + +def _wallet_executor(registry: Registry, source: Asset) -> str: + """Who signs the source-chain steps: the wallet of the chain the funds leave from.""" + family = registry.chain(source.chain_id).family + executor = WALLET_EXECUTOR_BY_FAMILY.get(family) + if executor is None: + raise BridgeError(f"No wallet executor for chain family {family!r} ({source.chain_id})") + return executor + + +def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipient: str, sender: str | None, + mint_mode: str = "public") -> Plan: + """Build the ``Plan`` for one bridge route (mirrors veil ``prepare`` steps, brief §2.1).""" + source: Asset = registry.asset(route.source_asset_id) + destination: Asset = registry.asset(route.destination_asset_id) + if mint_mode not in ("public", "record", "private"): + raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") + if mint_mode != "public" and route.protocol != "xreserve": + raise BridgeError("mint_mode other than public applies only to xReserve deposits to Aleo") + if amount_atomic <= 0: + raise BridgeError("amount_atomic must be positive") + wallet = _wallet_executor(registry, source) + source_family = registry.chain(source.chain_id).family + destination_family = registry.chain(destination.chain_id).family + if route.protocol == "xreserve": + if source_family == "evm" and destination_family == "aleo": + steps = (Step("source-approval", "approve", wallet, False), + Step("source-deposit", "deposit", wallet, True), + Step("deposit-attestation", "wait-attestation", "protocol", False), + Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) + elif source_family == "aleo" and destination_family == "evm": + steps = (Step("source-burn", "burn", wallet, True), + Step("withdrawal-attestation", "wait-attestation", "protocol", False), + Step("destination-withdrawal", "withdraw", "protocol", False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) + else: + raise UnsupportedRouteError(f"Unsupported xReserve route direction: {route.id}") + else: + # Aleo ARC-20 tokens need no on-chain approval; only a non-Aleo token source does. + needs_approval = source.kind == "token" and source_family != "aleo" + steps = tuple([Step("source-approval", "approve", wallet, False)] if needs_approval else []) + ( + Step("source-dispatch", "dispatch", wallet, True), + Step("message-delivery", "wait-delivery", "protocol", False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) + return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, + environment=route.environment, source_asset_id=source.id, destination_asset_id=destination.id, + amount=format_decimal_amount(amount_atomic, source.decimals), amount_atomic=amount_atomic, + recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) + + +__all__ = ["WALLET_EXECUTOR_BY_FAMILY", "build_plan"] diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py new file mode 100644 index 0000000..3bf6c1e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -0,0 +1,386 @@ +"""Verbatim port of veil ``packages/bridge/src/registry/default.ts`` (commit 8d62198, registry version +2026-08-31.solana-deposits.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 + +REGISTRY_VERSION = "2026-08-31.solana-deposits.1" + +EVM_ADDRESS = "^0x[0-9a-fA-F]{40}$" +SOLANA_ADDRESS = "^[1-9A-HJ-NP-Za-km-z]{32,44}$" +ALEO_ADDRESS = "^aleo1[0-9a-z]{58}$" + +CHAINS = [ + {"id": "aleo", "displayName": "Aleo", "family": "aleo", "environment": "mainnet", "nativeCurrencySymbol": "ALEO", + "protocolDomains": {"xreserve": 10002, "hyperlane": 1634493807}}, + {"id": "ethereum", "displayName": "Ethereum", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"xreserve": 0, "hyperlane": 1}}, + {"id": "solana", "displayName": "Solana", "family": "solana", "environment": "mainnet", "nativeCurrencySymbol": "SOL", + "protocolDomains": {"hyperlane": 1399811149}}, + {"id": "base", "displayName": "Base", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH"}, + {"id": "hyperevm", "displayName": "HyperEVM", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "HYPE"}, + {"id": "aleo-testnet", "displayName": "Aleo Testnet", "family": "aleo", "environment": "testnet", "nativeCurrencySymbol": "ALEO", + "protocolDomains": {"xreserve": 10002, "hyperlane": 1617853565}}, + {"id": "sepolia", "displayName": "Ethereum Sepolia", "family": "evm", "environment": "testnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"hyperlane": 11155111}}, +] + +ASSETS = [ + {"id": "aleo/aleo", "key": "aleo", "chainId": "aleo", "symbol": "ALEO", "name": "Aleo", "decimals": 6, "kind": "native", + "locator": {"kind": "aleo-program", "value": "credits.aleo"}, "addressValidationRegex": ALEO_ADDRESS}, + {"id": "aleo/usdcx", "key": "usdcx", "chainId": "aleo", "symbol": "USDCx", "name": "USDCx", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "usdcx_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS, + "privacy": {"kind": "arc22", "program": "usdcx_stablecoin.aleo"}}, + {"id": "aleo/eth", "key": "eth", "chainId": "aleo", "symbol": "ETH", "name": "Hyperlane ETH", "decimals": 18, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_eth_v2.aleo", + "tokenId": "aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_eth.aleo"}}, + {"id": "aleo/wbtc", "key": "wbtc", "chainId": "aleo", "symbol": "WBTC", "name": "Hyperlane WBTC", "decimals": 8, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_wbtc_v2.aleo", + "tokenId": "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_wbtc.aleo"}}, + {"id": "aleo/usdt", "key": "usdt", "chainId": "aleo", "symbol": "USDT", "name": "Hyperlane USDT", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_usdt_v2.aleo", + "tokenId": "aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_usdt.aleo"}}, + {"id": "aleo/sol", "key": "sol", "chainId": "aleo", "symbol": "SOL", "name": "Hyperlane SOL", "decimals": 9, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_sol_v2.aleo", + "tokenId": "aleo1aa0zt0vg9uwknekpqeefkvad55swp7833wc5crp2prv0lm4djuxs5r7k6v"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_sol.aleo"}}, + {"id": "aleo/usad", "key": "usad", "chainId": "aleo", "symbol": "USAD", "name": "USAD", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "usad_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS}, + {"id": "ethereum/usdc", "key": "usdc", "chainId": "ethereum", "symbol": "USDC", "name": "USD Coin", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/eth", "key": "eth", "chainId": "ethereum", "symbol": "ETH", "name": "Ether", "decimals": 18, "kind": "native", + "locator": {"kind": "native", "value": "ETH"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/wbtc", "key": "wbtc", "chainId": "ethereum", "symbol": "WBTC", "name": "Wrapped Bitcoin", "decimals": 8, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/usdt", "key": "usdt", "chainId": "ethereum", "symbol": "USDT", "name": "Tether USD", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0xdAC17F958D2ee523a2206206994597C13D831ec7"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/aleo", "key": "aleo", "chainId": "ethereum", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/usad", "key": "usad", "chainId": "ethereum", "symbol": "USAD", "name": "USAD route collateral", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "solana/sol", "key": "sol", "chainId": "solana", "symbol": "SOL", "name": "Solana", "decimals": 9, "kind": "native", + "locator": {"kind": "native", "value": "SOL"}, "addressValidationRegex": SOLANA_ADDRESS}, + {"id": "solana/aleo", "key": "aleo", "chainId": "solana", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": SOLANA_ADDRESS}, + {"id": "base/aleo", "key": "aleo", "chainId": "base", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "hyperevm/aleo", "key": "aleo", "chainId": "hyperevm", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "aleo-testnet/usdcx", "key": "usdcx", "chainId": "aleo-testnet", "symbol": "USDCx", "name": "Testnet USDCx", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "test_usdcx_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS, + "privacy": {"kind": "arc22", "program": "test_usdcx_stablecoin.aleo"}}, + {"id": "sepolia/usdc", "key": "usdc", "chainId": "sepolia", "symbol": "USDC", "name": "Testnet USD Coin", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}, "addressValidationRegex": EVM_ADDRESS}, +] + +XRESERVE_SOURCE = "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains" +HYPERLANE_REGISTRY_COMMIT = "2621c16f2db1ccb46643265c110dac5ca2c7c51a" +HYPERLANE_SOURCE = f"https://github.com/hyperlane-xyz/hyperlane-registry/tree/{HYPERLANE_REGISTRY_COMMIT}/deployments/warp_routes" + +ALEO_USDT_HYPERLANE_CONFIG_SOURCE = "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/USDT/aleo-config.yaml" +ALEO_SOL_HYPERLANE_CONFIG_SOURCE = "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml" + +ETHEREUM_HYPERLANE_COMMON = { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": HYPERLANE_REGISTRY_COMMIT, +} +ETH_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "routerType": "native", + "destinationRouter": "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8", +} +WBTC_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "routerType": "collateral", + "tokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "destinationRouter": "hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf", +} +USDT_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "routerType": "collateral", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "destinationRouter": "hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y", + "requiresApprovalReset": True, +} + +# Intentionally non-live values that only expose the transfer_remote ABI; execution refuses them. +ALEO_PLACEHOLDER_ADDRESS = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO_PLACEHOLDER_BYTES32 = "[" + ", ".join(["0u8"] * 32) + "]" +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +IGP_HOOK = "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" + +ALEO_MAILBOX_METADATA = { + "aleoMailboxStateVerified": True, + "aleoHookManagerProgram": "hyp_hook_manager.aleo", + "aleoHookManagerProgramSource": "https://explorer.provable.com/program/hyp_hook_manager.aleo", + "aleoMailboxProgram": "hyp_mailbox.aleo", + "aleoMailboxProgramEdition": 0, + "aleoMailboxProgramSource": "https://explorer.provable.com/program/hyp_mailbox.aleo", + "aleoMailboxMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_mailbox.aleo/mapping/mailbox/true", + "aleoMailboxMetadataReviewedAt": "2026-08-17", + "aleoMailboxLocalDomain": 1634493807, + "aleoMailboxObservedNonce": 170, + "aleoMailboxObservedProcessCount": 291, + "aleoMailboxDefaultIsm": "aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk", + "aleoMailboxDefaultHook": IGP_HOOK, + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", +} + + +def _aleo_hyperlane_placeholders(program: str, destination_domain: int) -> dict: + return { + "aleoRouterProgram": program, + "aleoDestinationDomain": destination_domain, + "aleoPlaceholderConfiguration": True, + "aleoTokenType": "0", + "aleoTokenOwner": ALEO_PLACEHOLDER_ADDRESS, + "aleoIsm": ALEO_PLACEHOLDER_ADDRESS, + "aleoHook": ALEO_PLACEHOLDER_ADDRESS, + "aleoTokenId": "0field", + "aleoRemoteRouterRecipient": ALEO_PLACEHOLDER_BYTES32, + "aleoRemoteRouterGas": "0", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount3": "0", + **ALEO_MAILBOX_METADATA, + } + + +ALEO_WBTC_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_wbtc_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "1505227928464760254508513036497943623956572091841806589002910775534260084309field", + "aleoLocalDecimals": 8, + "aleoRemoteDecimals": 8, +} +ALEO_ETH_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_eth_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_eth_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "133188123661477349522757068766864658505569365361420630212878794317749195359field", + "aleoLocalDecimals": 18, + "aleoRemoteDecimals": 18, +} +ALEO_USDT_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_usdt_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_usdt_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 1, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "8295938150000417034830036849466229528602563851235385582732969109393809606969field", + "aleoLocalDecimals": 6, + "aleoRemoteDecimals": 18, + "aleoScale": "1000000000000", + "aleoHyperlaneConfigSource": ALEO_USDT_HYPERLANE_CONFIG_SOURCE, +} +ALEO_SOL_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_sol_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_sol_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "6148061383892805373029428966764338809222769879628268522058032128225601478383field", + "aleoLocalDecimals": 9, + "aleoRemoteDecimals": 9, + "aleoHyperlaneConfigSource": ALEO_SOL_HYPERLANE_CONFIG_SOURCE, +} + +_ALLOWANCES = { + "aleoAllowanceSpendersVerified": True, + "aleoUnusedAllowancesVerified": True, + "aleoAllowanceSpender0": IGP_HOOK, + "aleoAllowanceSpender1": ZERO_ADDRESS, + "aleoAllowanceSpender2": ZERO_ADDRESS, + "aleoAllowanceSpender3": ZERO_ADDRESS, + "aleoAllowanceAmount1": "0", + "aleoAllowanceAmount2": "0", + "aleoAllowanceAmount3": "0", +} +ALEO_ETH_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_eth_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransferSource": "https://explorer.provable.com/transaction/at1vu0yckkms887zkl3qz7plnncd56jtf5zeal4uj2808upsjkusy8q7yp9v8", + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8]", + "aleoRemoteRouterGas": "44000", + **_ALLOWANCES, +} +ALEO_WBTC_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8]", + "aleoRemoteRouterGas": "68000", + **_ALLOWANCES, +} +ALEO_USDT_ETHEREUM_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_usdt_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransferSource": "https://explorer.provable.com/transaction/at19caeeee8v3xc4kfwen4tx89f0tnggrpjp0anrhq2ca3y82xr9q8qyz8a9r", + "aleoSampleTransferDestinationDomain": 56, + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8]", + "aleoRemoteRouterGas": "68000", + **_ALLOWANCES, +} +ALEO_SOL_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_sol_v2.aleo/mapping/remote_routers/1399811149u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransitionId": "au15fg39h53h55tkj0nexrme3k6pvgxngxapcyajdhf06jcg3cyeugq5kd7hg", + "aleoDestinationDomain": 1399811149, + "aleoRemoteRouterSolanaAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "aleoRemoteRouterRecipient": "[112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8]", + "aleoRemoteRouterGas": "300000", + **_ALLOWANCES, +} +ALEO_WITHDRAWAL_ACTIVATION = {"aleoPlaceholderConfiguration": False, "aleoWithdrawalReviewedAt": "2026-08-26"} + +SOLANA_SOL_DEPOSIT_METADATA = { + "warpProgramAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "tokenPda": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "nativeCollateralPda": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", + "dispatchAuthorityPda": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "mailboxProgramAddress": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", + "mailboxOutboxPda": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "igpProgramAddress": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", + "igpProgramDataPda": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "igpAccount": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "igpOverheadAccount": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "splNoopProgramAddress": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", + "destinationDomain": 1634493807, + "destinationGasAmount": "464000", + "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", + "solanaReviewedAt": "2026-08-31", + "solanaConfigSource": ALEO_SOL_HYPERLANE_CONFIG_SOURCE, +} + +XRESERVE_MAINNET_METADATA = { + "xReserveContract": "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", + "sourceChainId": 1, + "sourceDomain": 0, + "ethereumDestinationDomain": 0, + "arcDestinationDomain": 26, + "remoteDomain": 10002, + "remoteToken": "usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711", + "minimumAmountAtomic": "2000000", + "withdrawalFeeAtomic": "2000000", + "maxFeeAtomic": "100000", + "bridgeProgram": "usdcx_bridge_v2.aleo", + "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api.circle.com/v1/attestations", +} +XRESERVE_TESTNET_METADATA = { + "xReserveContract": "0x008888878f94C0d87defdf0B07f46B93C1934442", + "sourceChainId": 11155111, + "sourceDomain": 0, + "ethereumDestinationDomain": 0, + "arcDestinationDomain": 26, + "remoteDomain": 10002, + "remoteToken": "test_usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401", + "minimumAmountAtomic": "2000000", + "withdrawalFeeAtomic": "2000000", + "maxFeeAtomic": "100000", + "bridgeProgram": "test_usdcx_bridge_v2.aleo", + "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api-testnet.circle.com/v1/attestations", +} + + +def _route(id: str, protocol: str, environment: str, source_asset_id: str, destination_asset_id: str, + availability: str, deployment_id: str, metadata: dict) -> dict: + return { + "id": id, "protocol": protocol, "environment": environment, + "sourceAssetId": source_asset_id, "destinationAssetId": destination_asset_id, + "availability": availability, "deploymentId": deployment_id, + "source": XRESERVE_SOURCE if protocol == "xreserve" else HYPERLANE_SOURCE, + "metadata": dict(metadata), + } + + +def _pair(protocol: str, environment: str, left: str, right: str, availability: str, + deployment_id: str, metadata: dict) -> list[dict]: + return [ + _route(f"{protocol}:{left}->{right}", protocol, environment, left, right, availability, deployment_id, metadata), + _route(f"{protocol}:{right}->{left}", protocol, environment, right, left, availability, deployment_id, metadata), + ] + + +ROUTES = [ + *_pair("xreserve", "mainnet", "ethereum/usdc", "aleo/usdcx", "active", "xreserve-usdcx-aleo", XRESERVE_MAINNET_METADATA), + *_pair("xreserve", "testnet", "sepolia/usdc", "aleo-testnet/usdcx", "active", "xreserve-usdcx-aleo-testnet", XRESERVE_TESTNET_METADATA), + _route("hyperlane:ethereum/eth->aleo/eth", "hyperlane", "mainnet", "ethereum/eth", "aleo/eth", "active", "ETH/aleo", + {**ETH_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/eth->ethereum/eth", "hyperlane", "mainnet", "aleo/eth", "ethereum/eth", "active", "ETH/aleo", + {**ETH_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_eth_v2.aleo", 1), + **ALEO_ETH_APP_METADATA, **ALEO_ETH_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:ethereum/wbtc->aleo/wbtc", "hyperlane", "mainnet", "ethereum/wbtc", "aleo/wbtc", "active", "WBTC/aleo", + {**WBTC_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/wbtc->ethereum/wbtc", "hyperlane", "mainnet", "aleo/wbtc", "ethereum/wbtc", "active", "WBTC/aleo", + {**WBTC_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_wbtc_v2.aleo", 1), + **ALEO_WBTC_APP_METADATA, **ALEO_WBTC_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:ethereum/usdt->aleo/usdt", "hyperlane", "mainnet", "ethereum/usdt", "aleo/usdt", "active", "USDT/aleo", + {**USDT_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/usdt->ethereum/usdt", "hyperlane", "mainnet", "aleo/usdt", "ethereum/usdt", "active", "USDT/aleo", + {**USDT_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_usdt_v2.aleo", 1), + **ALEO_USDT_APP_METADATA, **ALEO_USDT_ETHEREUM_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:solana/sol->aleo/sol", "hyperlane", "mainnet", "solana/sol", "aleo/sol", "active", "SOL/aleo", + {**SOLANA_SOL_DEPOSIT_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/sol->solana/sol", "hyperlane", "mainnet", "aleo/sol", "solana/sol", "active", "SOL/aleo", + {**_aleo_hyperlane_placeholders("hyp_warp_token_sol_v2.aleo", 1399811149), + **ALEO_SOL_APP_METADATA, **ALEO_SOL_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + *_pair("hyperlane", "mainnet", "aleo/aleo", "ethereum/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "solana/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "base/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "hyperevm/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + _route("hyperlane:ethereum/usad->aleo/usad", "hyperlane", "mainnet", "ethereum/usad", "aleo/usad", "metadata-required", "USAD/aleo", + ALEO_MAILBOX_METADATA), + _route("hyperlane:aleo/usad->ethereum/usad", "hyperlane", "mainnet", "aleo/usad", "ethereum/usad", "metadata-required", "USAD/aleo", + _aleo_hyperlane_placeholders("hyp_warp_token_usad_v2.aleo", 1)), +] diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py new file mode 100644 index 0000000..c89379e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -0,0 +1,388 @@ +"""Pure Hyperlane sealevel (Solana) layouts for the SOL warp route. + +Nothing here imports solders or solana-py: inputs and outputs are ``bytes``, +``int`` and base58 ``str`` so every layout is testable on an Aleo-only +install. Sources: veil ``src/solana/SEALEVEL_NOTES.md`` (primary-source +derivations against hyperlane-monorepo 45c0988), ``src/solana/transferRemote.ts``, +``src/solana/igp.ts``, ``src/protocols/hyperlane/solanaMetadata.ts``, and the +recorded mainnet deposit in ``tests/fixtures/sealevel-transfer-remote.json``. +""" +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Mapping, Sequence + +from ._base58 import b58decode, b58encode +from .errors import BridgeError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteUnavailableError +from .registry import Route + +# SEALEVEL_NOTES §1: every Sealevel Hyperlane instruction is prefixed with this +# fixed 8-byte discriminator; TransferRemote is Borsh enum variant 1. +PROGRAM_INSTRUCTION_DISCRIMINATOR = bytes([1] * 8) +TRANSFER_REMOTE_VARIANT_TAG = 1 +INSTRUCTION_DATA_BYTES = 77 # 8 + 1 + 4 + 32 + 32 +U256_BYTES = 32 + + +def build_transfer_remote_instruction_data(destination_domain: int, recipient32: bytes, amount: int) -> bytes: + """``[8B 0x01×8][1B 0x01][u32 LE domain][32B recipient][u256 LE amount]`` — 77 bytes. + + ``recipient32`` is the raw bech32m payload from ``encoding.aleo_address_to_bytes32`` + (no byte reversal); ``amount`` is lamports. + """ + if not 0 <= destination_domain <= 0xFFFF_FFFF: + raise ConfigurationError(f"destination domain {destination_domain} does not fit in a u32") + if len(recipient32) != 32: + raise InvalidRecipientError(f"recipient must be exactly 32 bytes, got {len(recipient32)}") + if not 0 <= amount < (1 << (U256_BYTES * 8)): + raise InvalidAmountError("amount does not fit in a 32-byte unsigned integer") + data = ( + PROGRAM_INSTRUCTION_DISCRIMINATOR + + bytes([TRANSFER_REMOTE_VARIANT_TAG]) + + destination_domain.to_bytes(4, "little") + + bytes(recipient32) + + amount.to_bytes(U256_BYTES, "little") + ) + assert len(data) == INSTRUCTION_DATA_BYTES + return data + + +# SEALEVEL_NOTES §4: AccountData> layout and compute_gas_fee constants. +IGP_DISCRIMINATOR = b"IGP_____" +TOKEN_EXCHANGE_RATE_SCALE = 10 ** 19 # exchange rate 1.0 is stored as 10^19 +SOL_DECIMALS = 9 +GAS_ORACLE_ENTRY_BYTES = 38 # [4B domain][1B tag][16B exchange rate][16B gas price][1B decimals] +REMOTE_GAS_DATA_TAG = 0 # the only GasOracle variant defined today + + +@dataclass(frozen=True) +class GasOracle: + token_exchange_rate: int + gas_price: int + token_decimals: int + + +@dataclass(frozen=True) +class IgpAccount: + bump: int + salt: bytes + owner: str | None + beneficiary: str + gas_oracles: dict[int, GasOracle] + unsupported_oracles: dict[int, int] # domain -> variant tag, for entries that are not RemoteGasData + + +class _Cursor: + """Little-endian Borsh reader over immutable bytes.""" + + def __init__(self, data: bytes) -> None: + self._data = bytes(data) + self._offset = 0 + + def take(self, size: int) -> bytes: + end = self._offset + size + if end > len(self._data): + raise ConfigurationError("malformed Sealevel IGP account data: declared layout exceeds the supplied bytes") + chunk = self._data[self._offset:end] + self._offset = end + return chunk + + def u8(self) -> int: + return self.take(1)[0] + + def u32(self) -> int: + return int.from_bytes(self.take(4), "little") + + def u128(self) -> int: + return int.from_bytes(self.take(16), "little") + + def pubkey(self) -> str: + return b58encode(self.take(32)) + + +def decode_igp_account(data: bytes) -> IgpAccount: + """Decode the terminal ``Igp`` account (the ``inner`` of an OverheadIgp), SEALEVEL_NOTES §4.""" + cursor = _Cursor(data) + if cursor.u8() != 1: + raise ConfigurationError("Sealevel IGP account is not initialized") + discriminator = cursor.take(8) + if discriminator != IGP_DISCRIMINATOR: + raise ConfigurationError( + f"Sealevel IGP account has an unexpected discriminator {discriminator!r}; expected {IGP_DISCRIMINATOR!r}" + ) + bump = cursor.u8() + salt = cursor.take(32) + owner_tag = cursor.u8() + if owner_tag not in (0, 1): + raise ConfigurationError(f"malformed Sealevel IGP account data: unsupported owner option tag {owner_tag}") + owner = cursor.pubkey() if owner_tag == 1 else None + beneficiary = cursor.pubkey() + count = cursor.u32() + oracles: dict[int, GasOracle] = {} + unsupported: dict[int, int] = {} + for _ in range(count): + domain = cursor.u32() + tag = cursor.u8() + exchange_rate = cursor.u128() + gas_price = cursor.u128() + decimals = cursor.u8() + if tag == REMOTE_GAS_DATA_TAG: + oracles[domain] = GasOracle(exchange_rate, gas_price, decimals) + else: + unsupported[domain] = tag + return IgpAccount(bump, salt, owner, beneficiary, oracles, unsupported) + + +def igp_lamports(oracle: GasOracle, gas_amount: int) -> int: + """``compute_gas_fee`` + ``convert_decimals`` (SEALEVEL_NOTES §4), exact integer arithmetic.""" + destination_cost = gas_amount * oracle.gas_price + origin_cost = destination_cost * oracle.token_exchange_rate // TOKEN_EXCHANGE_RATE_SCALE + if oracle.token_decimals <= SOL_DECIMALS: + return origin_cost * 10 ** (SOL_DECIMALS - oracle.token_decimals) + return origin_cost // 10 ** (oracle.token_decimals - SOL_DECIMALS) + + +def quote_igp_lamports(igp_account_data: bytes, destination_domain: int, gas_amount: int) -> int: + """Lamports the IGP charges to deliver ``gas_amount`` destination gas to ``destination_domain``. + + ``gas_amount`` is the warp token's ``destination_gas`` for the domain (route metadata + ``destinationGasAmount``, 464000 for Aleo), not derived from the message. + """ + account = decode_igp_account(igp_account_data) + if destination_domain in account.unsupported_oracles: + tag = account.unsupported_oracles[destination_domain] + raise ConfigurationError( + f"Sealevel IGP account has an unexpected GasOracle variant tag {tag} for domain {destination_domain}; " + "only variant 0 (RemoteGasData) is decoded" + ) + oracle = account.gas_oracles.get(destination_domain) + if oracle is None: + raise ConfigurationError(f"Sealevel IGP account has no gas-oracle entry for destination domain {destination_domain}") + return igp_lamports(oracle, gas_amount) + + +# --- Route metadata ---------------------------------------------------------------------------- + +SOLANA_ROUTE_ID = "hyperlane:solana/sol->aleo/sol" +SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111" +SOLANA_PUBKEY_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") + +# SEALEVEL_NOTES §3: seeds are separate byte strings (separators are their own seed). +DISPATCHED_MESSAGE_SEED_PREFIX = (b"hyperlane", b"-", b"dispatched_message", b"-") +GAS_PAYMENT_SEED_PREFIX = (b"hyperlane_igp", b"-", b"gas_payment", b"-") +PDA_MARKER = b"ProgramDerivedAddress" +MAX_SEEDS = 16 +MAX_SEED_LENGTH = 32 + +# veil protocols/hyperlane/solana.ts: rent for the two accounts a transfer creates (gas-payment PDA, +# dispatched-message PDA) plus the sender's own rent floor; compute-unit limit set on every transfer. +GAS_PAYMENT_ACCOUNT_DATA_LENGTH = 141 +DISPATCHED_MESSAGE_ACCOUNT_DATA_LENGTH = 194 +COMPUTE_UNIT_LIMIT = 400_000 + + +@dataclass(frozen=True) +class SolanaRouteMetadata: + warp_program_address: str + token_pda: str + native_collateral_pda: str + dispatch_authority_pda: str + mailbox_program_address: str + mailbox_outbox_pda: str + igp_program_address: str + igp_program_data_pda: str + igp_account: str + igp_overhead_account: str | None + spl_noop_program_address: str + destination_domain: int + destination_gas_amount: int + registry_commit: str + solana_reviewed_at: str + solana_config_source: str + + +_PUBKEY_FIELDS = ( + ("warpProgramAddress", "warp_program_address"), + ("tokenPda", "token_pda"), + ("nativeCollateralPda", "native_collateral_pda"), + ("dispatchAuthorityPda", "dispatch_authority_pda"), + ("mailboxProgramAddress", "mailbox_program_address"), + ("mailboxOutboxPda", "mailbox_outbox_pda"), + ("igpProgramAddress", "igp_program_address"), + ("igpProgramDataPda", "igp_program_data_pda"), + ("igpAccount", "igp_account"), + ("splNoopProgramAddress", "spl_noop_program_address"), +) + + +def solana_route_metadata(route: Route) -> SolanaRouteMetadata: + """Validate and return the reviewed Solana deployment metadata (veil ``solanaMetadata.ts``). + + Every address participates in instruction account ordering, so the whole route is + refused when one field is missing or malformed rather than letting a bad key through. + """ + if route.protocol != "hyperlane": + raise RouteUnavailableError(f"Solana Hyperlane actions require a Hyperlane route, got {route.protocol}: {route.id}") + if route.availability != "active": + raise RouteUnavailableError(f"Hyperlane route is not executable: {route.id} ({route.availability})") + metadata: Mapping[str, object] = route.metadata or {} + + def pubkey(key: str) -> str: + value = metadata.get(key) + if not isinstance(value, str) or not SOLANA_PUBKEY_RE.match(value): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid {key}: {route.id}") + return value + + fields = {attr: pubkey(key) for key, attr in _PUBKEY_FIELDS} + overhead = metadata.get("igpOverheadAccount") + fields["igp_overhead_account"] = None if overhead is None else pubkey("igpOverheadAccount") + + domain = metadata.get("destinationDomain") + if isinstance(domain, bool) or not isinstance(domain, int) or not 0 <= domain <= 0xFFFF_FFFF: + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid destinationDomain: {route.id}") + gas = metadata.get("destinationGasAmount") + if isinstance(gas, bool) or not (isinstance(gas, int) or (isinstance(gas, str) and gas.isdigit())): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid destinationGasAmount: {route.id}") + commit = metadata.get("registryCommit") + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", commit): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid registryCommit: {route.id}") + reviewed = metadata.get("solanaReviewedAt") + if not isinstance(reviewed, str) or not re.match(r"^\d{4}-\d{2}-\d{2}", reviewed): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid solanaReviewedAt: {route.id}") + source = metadata.get("solanaConfigSource") + if not isinstance(source, str) or not source: + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid solanaConfigSource: {route.id}") + return SolanaRouteMetadata( + destination_domain=domain, + destination_gas_amount=int(gas), + registry_commit=commit, + solana_reviewed_at=reviewed, + solana_config_source=source, + **fields, + ) + + +# --- PDAs ------------------------------------------------------------------------------------- + +_ED25519_P = 2 ** 255 - 19 +_ED25519_D = (-121665 * pow(121666, -1, _ED25519_P)) % _ED25519_P + + +def _is_on_curve(point: bytes) -> bool: + """Whether a compressed Edwards Y coordinate decompresses (curve25519-dalek ``decompress``): + x² = (y² − 1) / (d·y² + 1) must have a square root (or be zero).""" + y = (int.from_bytes(point, "little") & ((1 << 255) - 1)) % _ED25519_P + y2 = y * y % _ED25519_P + u = (y2 - 1) % _ED25519_P + v = (_ED25519_D * y2 + 1) % _ED25519_P + if v == 0: + return u == 0 + x2 = u * pow(v, -1, _ED25519_P) % _ED25519_P + return x2 == 0 or pow(x2, (_ED25519_P - 1) // 2, _ED25519_P) == 1 + + +def create_program_address(seeds: Sequence[bytes], program_id: str) -> str | None: + """``Pubkey::create_program_address``: sha256(seeds ‖ program_id ‖ marker); None when on-curve.""" + if len(seeds) > MAX_SEEDS: + raise BridgeError(f"program address derivation accepts at most {MAX_SEEDS} seeds") + digest = hashlib.sha256() + for seed in seeds: + if len(seed) > MAX_SEED_LENGTH: + raise BridgeError(f"each program address seed must be at most {MAX_SEED_LENGTH} bytes") + digest.update(bytes(seed)) + digest.update(b58decode(program_id)) + digest.update(PDA_MARKER) + candidate = digest.digest() + return None if _is_on_curve(candidate) else b58encode(candidate) + + +def find_program_address(seeds: Sequence[bytes], program_id: str) -> tuple[str, int]: + """``Pubkey::find_program_address``: try bump seeds 255 → 0, return the first off-curve address.""" + for bump in range(255, -1, -1): + address = create_program_address([*seeds, bytes([bump])], program_id) + if address is not None: + return address, bump + raise BridgeError("unable to find a viable program address bump seed") + + +def derive_dispatched_message_pda(mailbox_program_address: str, unique_message_address: str) -> str: + """Mailbox ``["hyperlane","-","dispatched_message","-", unique_message_pubkey]`` (SEALEVEL_NOTES §3).""" + return find_program_address([*DISPATCHED_MESSAGE_SEED_PREFIX, b58decode(unique_message_address)], mailbox_program_address)[0] + + +def derive_gas_payment_pda(igp_program_address: str, unique_message_address: str) -> str: + """IGP ``["hyperlane_igp","-","gas_payment","-", unique_message_pubkey]`` — same unique key as the message PDA.""" + return find_program_address([*GAS_PAYMENT_SEED_PREFIX, b58decode(unique_message_address)], igp_program_address)[0] + + +# --- Account table ---------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SolanaAccountMeta: + address: str + signer: bool + writable: bool + + def to_dict(self) -> dict[str, str | bool]: + return {"address": self.address, "signer": self.signer, "writable": self.writable} + + +def account_metas(metadata: SolanaRouteMetadata, sender: str, unique_message: str) -> list[SolanaAccountMeta]: + """The native-collateral ``TransferRemote`` account list, SEALEVEL_NOTES §2 rows 0–15. + + Row 12 (``igpOverheadAccount``) is present only when the route wraps its IGP in an + OverheadIgp; the list then has 16 entries, otherwise 15. The sender compiles writable + (the native-collateral ``transfer_in`` CPI debits it) and the unique-message account is + a read-only signer. + """ + def ro(address: str) -> SolanaAccountMeta: + return SolanaAccountMeta(address, False, False) + + def rw(address: str) -> SolanaAccountMeta: + return SolanaAccountMeta(address, False, True) + + dispatched_message = derive_dispatched_message_pda(metadata.mailbox_program_address, unique_message) + gas_payment = derive_gas_payment_pda(metadata.igp_program_address, unique_message) + metas = [ + ro(SYSTEM_PROGRAM_ADDRESS), # 0 + ro(metadata.spl_noop_program_address), # 1 + ro(metadata.token_pda), # 2 + ro(metadata.mailbox_program_address), # 3 + rw(metadata.mailbox_outbox_pda), # 4 + ro(metadata.dispatch_authority_pda), # 5 + SolanaAccountMeta(sender, True, True), # 6 sender / fee payer + SolanaAccountMeta(unique_message, True, False), # 7 unique message (readonly signer) + rw(dispatched_message), # 8 + ro(metadata.igp_program_address), # 9 + rw(metadata.igp_program_data_pda), # 10 + rw(gas_payment), # 11 + ] + if metadata.igp_overhead_account is not None: + metas.append(ro(metadata.igp_overhead_account)) # 12 (optional) + metas.extend([ + rw(metadata.igp_account), # 13 + ro(SYSTEM_PROGRAM_ADDRESS), # 14 + rw(metadata.native_collateral_pda), # 15 + ]) + return metas + + +# --- Program logs ------------------------------------------------------------------------------ + +# SEALEVEL_NOTES §5: only the Mailbox dispatch line carries the full id; the IGP and warp-completion +# lines print H256 with Display (truncated "0xffe0…7805") and must never be parsed. +# The trailing lookahead refuses a 65+-hex id outright rather than truncating it to a plausible-looking one. +DISPATCHED_MESSAGE_LOG_PATTERN = re.compile(r"Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})(?![0-9a-fA-F])") + + +def extract_hyperlane_message_id(logs: "list[str] | None") -> str | None: + """The 32-byte Hyperlane message id from confirmed program logs, or None when absent.""" + if not logs: + return None + for line in logs: + match = DISPATCHED_MESSAGE_LOG_PATTERN.search(line) + if match: + return match.group(1) + return None diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py new file mode 100644 index 0000000..d647e9b --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -0,0 +1,483 @@ +"""Framework-neutral agent tools over a :class:`~aleo_bridge.client.Bridge`. + +``bridge_tools()`` returns tool definitions in the Claude API ``tools=`` shape +(name / description / input_schema); ``dispatch_tool(bridge, name, args)`` +executes one and returns JSON-serializable data. Reads are open. Writes +require ``confirm: true`` — without it they return the quote (or recovered +progress / built call) plus ``how_to_confirm`` and move nothing. Agents never +carry ``Plan`` objects: ``bridge_execute`` takes the quote inputs and +re-quotes internally; recovery tools take the checkpoint dict returned by the +previous write. Amounts in and out are human units; ints stay ints. + +Three rules keep a model from doing damage with this surface: + +* **Secrets never leave the process.** ``_serialize`` drops the private-mint + secret, the Circle attestation body and the proved transaction bytes from any + receipt it renders, a previewed program call renders record-shaped inputs as + ``""``, and no tool ever echoes its own arguments back. (The + xReserve *hook data* — a public commitment, not the secret that opens it — + stays on the quote and inside the checkpoint, because ``lifecycle.resume`` + refuses to resume a deposit whose checkpoint has lost it.) +* **A private mint never gets a default secret.** ``mint_mode="private"`` + without a ``secret_nonce`` is a structured error, never a quiet ``"0scalar"`` + that commits to a hook nobody can reproduce. +* **Failures come back as data.** Every :class:`~aleo_bridge.errors.BridgeError` + is rendered ``{"error", "error_type", "how_to_fix"}`` so the model can fix the + input or set up the missing connection instead of crashing its own loop; a + send whose outcome is ambiguous also carries ``{"next": "recover"}`` and the + last checkpoint. Programming errors (``ValueError``, ``TypeError``) still + propagate. +""" +from __future__ import annotations + +import copy +import dataclasses +import enum +import re +from typing import Any, Callable + +from . import lifecycle +from .checkpoint import Checkpoint, create_checkpoint +from .errors import BridgeError, ConfigurationError +from .registry import DEFAULT_REGISTRY, Registry +from .types import EvmXReserveQuote, Receipt +from .units import format_decimal_amount + +_S = {"type": "string"} +_I = {"type": "integer"} +_B = {"type": "boolean"} +HOW_TO_CONFIRM = "re-call with confirm=true" + +EVM_HOW_TO_FIX = ("set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL in the environment (or pass " + "ethereum=Ethereum(...) to Bridge(...)) and retry") +SOLANA_HOW_TO_FIX = ("set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) in the environment (or pass " + "solana=Solana(...) to Bridge(...)) and retry") +NONCE_HOW_TO_FIX = ("re-call with secret_nonce set to the value the user kept from execute — the SDK " + "never stored it, and no other value can open the commitment") +RECOVER_HOW_TO_FIX = ("the source step may already be on the wire: call bridge_get_progress with the " + "checkpoint (or bridge_pending) before doing anything else — never bridge_execute again") + +#: Receipt ``protocol_state`` keys that are dropped from every rendered receipt: the private-mint +#: secret and record plaintext (secret), the Circle attestation body (secret-adjacent and large), +#: the proved transaction bytes and the hook commitment (both live in the checkpoint, which is what +#: the recovery verbs actually consume). +_REDACTED_STATE_KEYS = frozenset({ + "payload", "attestation", "secretnonce", "record", "recordplaintext", "privatekey", + "hookdata", "preparedtransaction", "prepareddestinationtransaction", +}) + + +def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: + return {"type": "object", "properties": properties, "required": required} + + +# ── serialization ───────────────────────────────────────────────────────────── + +def _max_fee_entry(quote: EvmXReserveQuote, registry: Registry | None) -> dict[str, Any] | None: + """The xReserve max fee as a fee entry: ``EvmXReserveQuote.fees`` is empty, and an agent that + cannot see the fee cannot tell the user what the transfer costs.""" + plan = quote.plan + if plan is None: + return None + try: + asset = (registry or DEFAULT_REGISTRY).asset(plan.source_asset_id) + amount = format_decimal_amount(quote.max_fee_atomic, asset.decimals) + except BridgeError: + return None + return {"kind": "protocol", "chain_id": asset.chain_id, "asset_id": asset.id, "amount": amount, + "estimated": True, "label": "xReserve max fee"} + + +def _serialize(value: Any, registry: Registry | None = None) -> Any: + """Dataclasses → dicts, bytes → 0x hex, enums → values, tuples → lists; ints stay ints. + + Checkpoints render through ``to_dict()`` (the camelCase form the recovery tools take back), + receipts lose their secret / bulky ``protocol_state`` entries, and an ``EvmXReserveQuote`` + gains the synthetic max-fee entry its empty ``fees`` tuple would otherwise hide. + """ + if isinstance(value, Checkpoint): + return value.to_dict() + if isinstance(value, Receipt): + return {f.name: (_redacted_state(getattr(value, f.name)) if f.name == "protocol_state" + else _serialize(getattr(value, f.name), registry)) + for f in dataclasses.fields(value)} + if isinstance(value, EvmXReserveQuote): + out = {f.name: _serialize(getattr(value, f.name), registry) for f in dataclasses.fields(value)} + entry = _max_fee_entry(value, registry) + if entry is not None: + out["fees"] = list(out.get("fees") or []) + [entry] + return out + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return {f.name: _serialize(getattr(value, f.name), registry) for f in dataclasses.fields(value)} + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (bytes, bytearray)): + return "0x" + bytes(value).hex() + if isinstance(value, dict): + return {str(k): _serialize(v, registry) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_serialize(v, registry) for v in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +#: A record, in any form an Aleo call input can carry it: the plaintext ``{ owner: … }`` a record +#: selection returns (``privacy.py``'s ``select_record``), the ``record1…`` ciphertext, a +#: ``x.record`` locator, or any plaintext carrying a nonce / a credits amount. A record IS the +#: private balance — showing one to a model (or writing it into a transcript) spends its privacy. +_RECORD_SHAPED = re.compile(r""" + (^record1[a-z0-9]{8,}) # record ciphertext + | (\.record\b) # a record locator + | (^\{\s*owner\s*:) # record plaintext, as select_record returns it + | (\b_nonce\s*:) # …or anything else carrying a record's nonce + | (\bmicrocredits\s*:) # …or a credits record's amount +""", re.IGNORECASE | re.VERBOSE) + + +def _summarize_input(value: Any) -> str: + """One program-call input, rendered for a human or a model: record-shaped → ``""``. + + Everything public — the amount literal, the recipient address, a Merkle path — passes through, + so the preview still says what the call does. + """ + text = value if isinstance(value, str) else str(value) + return "" if _RECORD_SHAPED.search(text.strip()) else text + + +def _redacted_state(state: Any) -> Any: + if not isinstance(state, dict): + return _serialize(state) + return {str(k): _serialize(v) for k, v in state.items() + if str(k).replace("_", "").lower() not in _REDACTED_STATE_KEYS} + + +# ── errors ──────────────────────────────────────────────────────────────────── + +def _how_to_fix(exc: Exception) -> str | None: + message = str(exc).lower() + if "secret_nonce" in message or "secret nonce" in message: + return NONCE_HOW_TO_FIX + if "evm_private_key" in message or "ethereum_rpc_url" in message or "ethereum=ethereum" in message: + return EVM_HOW_TO_FIX + if "solana" in message and ("not configured" in message or "solana_private_key" in message): + return SOLANA_HOW_TO_FIX + return None + + +def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: + payload: dict[str, Any] = {"error": str(exc), "error_type": exc.__class__.__name__} + fix = _how_to_fix(exc) + if fix is not None: + payload["how_to_fix"] = fix + payload.update(extra) + return payload + + +def _connection_gap(bridge: Any, source: Any) -> dict[str, Any] | None: + """Probe the connection a transfer out of *source* would sign with (spec §10: a missing + connection is a configuration answer the model can act on, not an exception).""" + try: + asset = bridge.registry.asset(source) + family = bridge.registry.chain(asset.chain_id).family + except BridgeError: + return None # let the real lookup produce the real error + if family == "evm" and getattr(bridge, "ethereum", None) is None: + return _error_payload(ConfigurationError( + f"No Ethereum connection is configured, and {asset.id} transfers are signed on " + f"{asset.chain_id}."), how_to_fix=EVM_HOW_TO_FIX) + if family == "solana" and getattr(bridge, "solana", None) is None: + return _error_payload(ConfigurationError( + f"No Solana connection is configured, and {asset.id} transfers are signed on " + f"{asset.chain_id}."), how_to_fix=SOLANA_HOW_TO_FIX) + return None + + +def _missing_nonce(what: str) -> dict[str, Any]: + return _error_payload(ConfigurationError( + f"a secret_nonce is required for a private mint: {what} commits to (recipient, secret_nonce) " + "and bridge_complete needs the same value again — keep it, the SDK never stores it"), + how_to_fix=NONCE_HOW_TO_FIX) + + +# ── shared argument handling ────────────────────────────────────────────────── + +_QUOTE_PROPS = { + "source": {**_S, "description": "Source asset as 'chain/key', e.g. 'ethereum/usdc', 'aleo/eth', 'solana/sol'."}, + "destination": {**_S, "description": "Destination asset as 'chain/key', e.g. 'aleo/usdcx', 'ethereum/eth'."}, + "amount": {**_S, "description": "Positive decimal amount in source-asset display units (e.g. '2', '0.001')."}, + "recipient": {**_S, "description": "Destination-chain address that receives the funds."}, + "sender": {**_S, "description": "Optional source-chain address; must be the configured connection's address."}, + "protocol": {**_S, "enum": ["xreserve", "hyperlane"], "description": "Only needed when both protocols serve the pair."}, + "mint_mode": {**_S, "enum": ["public", "record", "private"], + "description": "xReserve into Aleo only. 'private' requires the user to complete the mint later with the same secret_nonce."}, + "secret_nonce": {**_S, "description": "Private-mint commitment secret, REQUIRED when mint_mode is 'private' " + "(there is no default). The user must keep it for bridge_complete; the SDK never stores it."}, +} +_QUOTE_REQUIRED = ["source", "destination", "amount", "recipient"] +_CONFIRM = {"confirm": {**_B, "description": "Set true to move funds. Without it the quote is returned and nothing is submitted."}} +_CHECKPOINT = {"checkpoint": {"type": "object", + "description": "The checkpoint dict returned by bridge_get_progress, by an entry of " + "bridge_pending, or by bridge_execute / bridge_resume / bridge_complete " + "(including the one an interrupted write hands back with next='recover')."}} + + +def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: + """Quote inputs. ``secret_nonce`` defaults to ``"0scalar"`` only for a non-private mint — + a private one must carry its own (checked by :func:`_private_nonce_gap` first).""" + mint_mode = args.get("mint_mode") or "public" + secret_nonce = args.get("secret_nonce") + return dict(source=args["source"], destination=args["destination"], amount=str(args["amount"]), + recipient=args["recipient"], sender=args.get("sender"), protocol=args.get("protocol"), + mint_mode=mint_mode, + secret_nonce=secret_nonce if mint_mode == "private" else (secret_nonce or "0scalar")) + + +def _private_nonce_gap(args: dict[str, Any]) -> dict[str, Any] | None: + if (args.get("mint_mode") or "public") == "private" and not args.get("secret_nonce"): + return _missing_nonce("the deposit") + return None + + +def _with_checkpoint(bridge: Any, progress: Any) -> dict[str, Any]: + checkpoint = create_checkpoint(progress.plan, progress.receipt, bridge.registry) + return {"progress": _serialize(progress, bridge.registry), "checkpoint": checkpoint.to_dict()} + + +def _confirmation(**payload: Any) -> dict[str, Any]: + return {"confirmation_required": True, **payload, "how_to_confirm": HOW_TO_CONFIRM} + + +def _write(b: Any, call: Callable[[list[Any]], Any]) -> dict[str, Any]: + """Run one fund-moving verb, collecting its checkpoints, and render either outcome. + + Success is ``{"progress", "checkpoint"}``. A failure is never a retry cue: every write here is + single-use and a lost RPC answer is ambiguous, so the error comes back with ``next: "recover"``, + how-to-fix pointing at ``bridge_get_progress``, and the last checkpoint that made it out — + which, for a write interrupted after proving, is the only copy of those bytes. + """ + seen: list[Any] = [] + try: + progress = call(seen) + except BridgeError as exc: + payload = _error_payload(exc, next="recover") + payload["how_to_fix"] = RECOVER_HOW_TO_FIX + if seen: + payload["checkpoint"] = _serialize(seen[-1], b.registry) + return payload + return _with_checkpoint(b, progress) + + +# ── reads ───────────────────────────────────────────────────────────────────── + +def _h_status(b, a): + return _serialize(b.status(), b.registry) + + +def _h_list_assets(b, a): + return _serialize(b.registry.assets(chain=a.get("chain"), symbol=a.get("symbol"), + environment=a.get("environment", b.environment)), b.registry) + + +def _h_list_routes(b, a): + return _serialize(b.registry.routes(source=a.get("source"), destination=a.get("destination"), + protocol=a.get("protocol"), symbol=a.get("symbol"), + include_unavailable=bool(a.get("include_unavailable", False)), + environment=a.get("environment", b.environment)), b.registry) + + +def _h_quote(b, a): + gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source")) + if gap is not None: + return gap + return _serialize(lifecycle.quote(b, **_quote_kwargs(a)), b.registry) + + +def _h_get_progress(b, a): + # A checkpoint comes back with the progress: an agent that started from a stale one (or from + # bridge_pending) can hand this one straight to bridge_resume / bridge_complete. + return _with_checkpoint(b, lifecycle.recover(b, a["checkpoint"])) + + +def _h_pending(b, a): + """Every stored checkpoint with the ``Progress`` reconstructed for it — offline, one entry each. + + Reconstruction is :func:`lifecycle.progress_from_checkpoint`, exactly what ``Bridge.pending()`` + runs per record (no network read, so one unreachable chain can never hide the others). It is + called here rather than through ``Bridge.pending()`` because that verb returns bare ``Progress`` + objects: it neither pairs each one with the checkpoint that produced it — which is what the + recovery tools take back, and must be the STORED record, not one re-derived from a receipt an + offline reconstruction may have flattened — nor reports the records it could not interpret at + all (it drops them). Here such a record becomes one error entry naming its checkpoint id, and + the healthy entries still come back. + """ + store = getattr(b, "checkpoints", None) + if store is None: + return [] + out: list[dict[str, Any]] = [] + for cp in store.list(): + try: + progress = lifecycle.progress_from_checkpoint(b.registry, cp) + except BridgeError as exc: + out.append(_error_payload(exc, checkpoint_id=cp.id)) + continue + out.append({"progress": _serialize(progress, b.registry), "checkpoint": cp.to_dict()}) + return out + + +# ── writes (confirm-gated) ──────────────────────────────────────────────────── + +def _h_execute(b, a): + gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source")) + if gap is not None: + return gap + quote = lifecycle.quote(b, **_quote_kwargs(a)) + if not a.get("confirm"): + return _confirmation(quote=_serialize(quote, b.registry)) + return _write(b, lambda seen: lifecycle.execute( + b, quote.plan, on_checkpoint=seen.append, mode=a.get("mode"), proving=a.get("proving", "delegate"), + gas_payment_microcredits=a.get("gas_payment_microcredits"), secret_nonce=a.get("secret_nonce"))) + + +def _h_resume(b, a): + progress = lifecycle.recover(b, a["checkpoint"]) # reads only + if not a.get("confirm"): + return _confirmation(progress=_serialize(progress, b.registry)) + return _write(b, lambda seen: lifecycle.resume(b, progress, on_checkpoint=seen.append, + secret_nonce=a.get("secret_nonce"))) + + +def _has_prepared_destination(progress: Any) -> bool: + """A mint whose bytes are already proved rebroadcasts without the secret.""" + state = getattr(progress.receipt, "protocol_state", {}) or {} + return bool(state.get("preparedDestinationTransaction")) + + +def _h_complete(b, a): + progress = lifecycle.recover(b, a["checkpoint"]) # reads only + secret_nonce = a.get("secret_nonce") + if not secret_nonce and not _has_prepared_destination(progress): + return _missing_nonce("the deposit this mint finishes") + if not a.get("confirm"): + return _confirmation(progress=_serialize(progress, b.registry)) + return _write(b, lambda seen: lifecycle.complete(b, progress, on_checkpoint=seen.append, + secret_nonce=secret_nonce)) + + +def _privacy(b, a, direction: str): + kwargs = dict(asset=a["asset"], amount=a.get("amount"), amount_atomic=a.get("amount_atomic")) + call = b.shield(**kwargs) if direction == "shield" else b.unshield(**kwargs) + if not a.get("confirm"): + # An unshield's inputs carry the selected record's plaintext — the private balance itself. + return _confirmation(call={"program": call.program_id, "function": call.function_name, + "inputs": [_summarize_input(i) for i in call.inputs]}) + return _serialize(call.delegate(), b.registry) + + +def _h_shield(b, a): + return _privacy(b, a, "shield") + + +def _h_unshield(b, a): + return _privacy(b, a, "unshield") + + +# ── tool table ──────────────────────────────────────────────────────────────── + +_READ_TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ + ("bridge_status", + "Re-orient: environment, registry version, the configured Aleo/Ethereum/Solana addresses with balances of " + "every bridge asset (atomic units), and pending (in-flight) transfers. Run this FIRST in any session.", + _schema({}, []), _h_status), + ("bridge_list_assets", + "Assets that can be bridged, with chain, symbol, decimals and on-chain locator. Filter by chain id " + "(aleo, ethereum, solana, aleo-testnet, sepolia) or symbol.", + _schema({"chain": _S, "symbol": _S, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_assets), + ("bridge_list_routes", + "Supported directions and their protocol (xreserve = USDC<->USDCx via Circle; hyperlane = ETH/WBTC/USDT/SOL). " + "Active routes move funds; metadata-required ones are listed but refused by quote/execute.", + _schema({"source": _S, "destination": _S, "protocol": {**_S, "enum": ["xreserve", "hyperlane"]}, "symbol": _S, + "include_unavailable": _B, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_routes), + ("bridge_quote", + "Validate and price a transfer: route, fees (human units), amount_out, approval needs. Reads chain state, " + "never signs. ALWAYS quote before bridge_execute and show the user fees and amount_out. A private mint " + "(mint_mode='private') must carry the user's own secret_nonce — there is no default.", + _schema(_QUOTE_PROPS, _QUOTE_REQUIRED), _h_quote), + ("bridge_get_progress", + "Recover a transfer's state from a checkpoint (reads only). Returns {progress, checkpoint}: progress.next tells " + "what to do — wait (call again later), resume (bridge_resume), complete (bridge_complete), done, failed — and " + "the checkpoint is the fresh one to pass to whichever of those you call.", + _schema(_CHECKPOINT, ["checkpoint"]), _h_get_progress), + ("bridge_pending", + "Every in-flight transfer in this profile's checkpoint store, one {progress, checkpoint} entry each, " + "reconstructed offline (no chain read, so its progress can lag: bridge_get_progress refreshes one against live " + "state). A record too damaged to interpret comes back as {error, checkpoint_id} in its place.", + _schema({}, []), _h_pending), +] + +_WRITE_TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ + ("bridge_execute", + "Start a transfer: re-quotes the same inputs, then commits funds on the source chain. Requires confirm=true; " + "without it returns the quote and moves nothing. The source step is IRREVERSIBLE once broadcast — afterwards " + "use bridge_get_progress with the returned checkpoint, never bridge_execute again. For mint_mode=private the " + "user must supply secret_nonce here (no default) and keep it for bridge_complete.", + _schema({**_QUOTE_PROPS, "mode": {**_S, "description": "Aleo-origin only: caller|signer (Hyperlane) or " + "private|public|public-as-signer (xReserve burn)."}, + "gas_payment_microcredits": _I, "proving": {**_S, "enum": ["delegate", "local"]}, **_CONFIRM}, + _QUOTE_REQUIRED), _h_execute), + ("bridge_resume", + "Finish an interrupted source submission (progress.next == 'resume'): rebroadcasts the identical proved Aleo " + "transaction or authorizes the single missing EVM step after re-scanning history. Requires confirm=true. " + "An interrupted EVM xReserve deposit needs the same secret_nonce used at execute.", + _schema({**_CHECKPOINT, "secret_nonce": {**_S, "description": "The value used at bridge_execute; required to " + "resume a private-mint xReserve deposit."}, + **_CONFIRM}, ["checkpoint"]), _h_resume), + ("bridge_complete", + "Submit the private USDCx mint (progress.next == 'complete') with the secret_nonce used at execute. " + "Requires confirm=true. Submits exactly one Aleo transaction.", + _schema({**_CHECKPOINT, + "secret_nonce": {**_S, "description": "The private-mint secret used at bridge_execute — required " + "(there is no default); only an already-proved mint can be " + "rebroadcast without it."}, + **_CONFIRM}, ["checkpoint"]), _h_complete), + ("bridge_shield", + "Move a public Aleo balance of a bridged asset (aleo/eth, aleo/wbtc, aleo/usdt, aleo/sol, aleo/usdcx) into a " + "private record. Requires confirm=true; without it returns the program call for review.", + _schema({"asset": _S, "amount": _S, "amount_atomic": _I, **_CONFIRM}, ["asset"]), _h_shield), + ("bridge_unshield", + "Move a private record of a bridged asset back to the public balance (needed before an Aleo-origin Hyperlane " + "transfer). Requires confirm=true.", + _schema({"asset": _S, "amount": _S, "amount_atomic": _I, **_CONFIRM}, ["asset"]), _h_unshield), +] + +_HANDLERS: dict[str, Callable[[Any, dict[str, Any]], Any]] = { + name: handler for name, _, _, handler in _READ_TOOLS + _WRITE_TOOLS} + + +def bridge_tools(include_writes: bool = True) -> list[dict[str, Any]]: + """Tool definitions (Claude API ``tools=`` shape); ``include_writes=False`` keeps only reads. + + Every call returns a fresh deep copy: a caller that tailors a schema (or a framework that + annotates one in place) can never edit the module's own table out from under everyone else. + """ + tools = _READ_TOOLS + (_WRITE_TOOLS if include_writes else []) + return [{"name": name, "description": desc, "input_schema": copy.deepcopy(schema)} + for name, desc, schema, _ in tools] + + +def dispatch_tool(bridge: Any, name: str, args: dict[str, Any] | None = None) -> Any: + """Execute one tool against *bridge*; returns JSON-serializable data. + + A :class:`~aleo_bridge.errors.BridgeError` becomes ``{"error", "error_type", ...}`` — the model + gets to fix the input rather than lose its loop. An unknown tool name is a ``ValueError``. + """ + handler = _HANDLERS.get(name) + if handler is None: + raise ValueError(f"Unknown bridge tool: {name!r}") + try: + return handler(bridge, dict(args or {})) + except BridgeError as exc: + return _error_payload(exc) + + +__all__ = ["bridge_tools", "dispatch_tool", "HOW_TO_CONFIRM"] diff --git a/bridge-sdk/python/aleo_bridge/checkpoint.py b/bridge-sdk/python/aleo_bridge/checkpoint.py new file mode 100644 index 0000000..6b42d79 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/checkpoint.py @@ -0,0 +1,256 @@ +"""Checkpoints — the allowlisted, versioned recovery record (spec §8, brief §2.10). + +A checkpoint carries the public transfer intent, the route id + registry +version, and the transaction ids already submitted (plus, for Aleo legs, the +exact proved-but-unbroadcast transaction). It never carries keys, record +plaintext, the private-mint secret nonce, attestation bodies, payloads, +message hashes, nonces, or quote internals — ``create_checkpoint`` copies an +allowlist, not ``protocol_state``. +""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +from .errors import CheckpointInvalidError +from .registry import Registry +from .types import Plan, Receipt + +CHECKPOINT_VERSION = 1 +_DIGITS = re.compile(r"^\d+$") + + +@dataclass(frozen=True) +class Checkpoint: + """Version-1 recovery record. Dict/JSON keys are veil's camelCase names.""" + + version: int + receipt_id: str + intent: dict[str, Any] + route: dict[str, str] + source: dict[str, Any] | None = None + destination: dict[str, Any] | None = None + delivery_verification: dict[str, str] | None = None + + @property + def id(self) -> str: + """The receipt id this checkpoint was created from (store key).""" + return self.receipt_id + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"version": self.version, "receiptId": self.receipt_id, + "intent": self.intent, "route": self.route} + if self.source: + out["source"] = self.source + if self.destination: + out["destination"] = self.destination + if self.delivery_verification: + out["deliveryVerification"] = self.delivery_verification + return out + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2, sort_keys=True) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Checkpoint": + if not isinstance(data, dict) or data.get("version") != CHECKPOINT_VERSION \ + or not isinstance(data.get("intent"), dict) or not isinstance(data.get("route"), dict): + raise CheckpointInvalidError( + "Bridge checkpoint format is invalid or unsupported: expected version 1 " + "with 'intent' and 'route' objects") + source = data.get("source") or None + destination = data.get("destination") or None + receipt_id = data.get("receiptId") or _derive_id(source, destination) + return cls(version=1, receipt_id=receipt_id, intent=dict(data["intent"]), + route=dict(data["route"]), source=source, destination=destination, + delivery_verification=data.get("deliveryVerification") or None) + + @classmethod + def from_json(cls, text: str) -> "Checkpoint": + try: + return cls.from_dict(json.loads(text)) + except json.JSONDecodeError as exc: + raise CheckpointInvalidError(f"Bridge checkpoint is not valid JSON: {exc}") from exc + + +def _derive_id(source: dict[str, Any] | None, destination: dict[str, Any] | None) -> str: + """veil-shaped checkpoints have no receiptId: use the most recent transaction id.""" + for container, keys in ((destination, ("transactionId", "preparedTransaction")), + (source, ("preparedTransaction", "transactionId"))): + for key in keys: + value = (container or {}).get(key) + if isinstance(value, dict) and value.get("transactionId"): + return str(value["transactionId"]) + if isinstance(value, str) and value: + return value + approvals = (source or {}).get("approvalTransactionIds") or [] + if approvals: + return str(approvals[-1]) + raise CheckpointInvalidError( + "Bridge checkpoint contains no submitted or prepared transaction to identify it by") + + +def _validated_prepared(value: Any, what: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise CheckpointInvalidError(f"Bridge receipt contains an invalid {what}") + return value + + +def create_checkpoint(plan: Plan, receipt: Receipt, registry: Registry) -> Checkpoint: + """Reduce *receipt* to the documented recovery fields (allowlist, brief §2.10). + + Raises :class:`CheckpointInvalidError` when the receipt belongs to another + route/protocol or carries malformed approval ids, prepared transactions, + Solana blockhash lifetime, or delivery-verification state. + """ + state = receipt.protocol_state + if receipt.protocol != plan.protocol or state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Bridge receipt does not match the prepared route") + + raw_approvals = state.get("approvalTxIds") + if raw_approvals is not None and (not isinstance(raw_approvals, list) + or any(not isinstance(v, str) for v in raw_approvals)): + raise CheckpointInvalidError("Bridge receipt contains invalid approval transaction identifiers") + approvals = list(raw_approvals or []) + + source_sender = state.get("sourceSender") + if source_sender is not None and not isinstance(source_sender, str): + raise CheckpointInvalidError("Bridge receipt contains an invalid source sender") + sender = plan.sender or source_sender + + prepared = _validated_prepared(state.get("preparedTransaction"), "prepared transaction") + prepared_destination = _validated_prepared(state.get("preparedDestinationTransaction"), + "prepared destination transaction") + + blockhash = state.get("blockhash") + last_valid = state.get("lastValidBlockHeight") + if (blockhash is not None or last_valid is not None) and ( + not isinstance(blockhash, str) or not blockhash + or not isinstance(last_valid, str) or not _DIGITS.match(last_valid)): + raise CheckpointInvalidError("Bridge receipt contains an invalid Solana blockhash lifetime") + + before = state.get("destinationBalanceBeforeAtomic") + expected = state.get("expectedDestinationIncreaseAtomic") + if (before is not None or expected is not None) and ( + not isinstance(before, str) or not _DIGITS.match(before) + or not isinstance(expected, str) or not _DIGITS.match(expected)): + raise CheckpointInvalidError( + "Bridge receipt contains invalid destination balance verification state") + + source: dict[str, Any] | None = None + if approvals or receipt.source_tx_id or prepared: + source = {} + if approvals: + source["approvalTransactionIds"] = approvals + if receipt.source_tx_id: + source["transactionId"] = receipt.source_tx_id + if isinstance(state.get("hookData"), str): + source["hookData"] = state["hookData"] + if isinstance(blockhash, str) and isinstance(last_valid, str): + source["blockhash"] = blockhash + source["lastValidBlockHeight"] = last_valid + if prepared is not None: + source["preparedTransaction"] = {"transactionId": receipt.id, + "serializedTransaction": prepared} + + destination: dict[str, Any] | None = None + if receipt.destination_tx_id or prepared_destination is not None: + destination = {} + if receipt.destination_tx_id: + destination["transactionId"] = receipt.destination_tx_id + if prepared_destination is not None: + destination["preparedTransaction"] = {"transactionId": receipt.id, + "serializedTransaction": prepared_destination} + + src_asset = registry.asset(plan.source_asset_id) + dst_asset = registry.asset(plan.destination_asset_id) + intent: dict[str, Any] = { + "source": {"chain": src_asset.chain_id, "asset": src_asset.key}, + "destination": {"chain": dst_asset.chain_id, "asset": dst_asset.key}, + "bridgeProtocol": plan.protocol, + "amount": plan.amount, + "recipient": plan.recipient, + } + if sender: + intent["sender"] = sender + if dst_asset.locator is not None and dst_asset.locator.kind == "aleo-program": + intent["mintMode"] = plan.mint_mode + + verification = ({"balanceBeforeAtomic": before, "expectedIncreaseAtomic": expected} + if isinstance(before, str) and isinstance(expected, str) else None) + return Checkpoint(version=CHECKPOINT_VERSION, receipt_id=receipt.id, intent=intent, + route={"id": plan.route_id, "registryVersion": plan.registry_version}, + source=source, destination=destination, delivery_verification=verification) + + +@runtime_checkable +class CheckpointStore(Protocol): + """Where checkpoints live between processes. Implement all four methods.""" + + def save(self, checkpoint: Checkpoint) -> None: ... + def load(self, checkpoint_id: str) -> Checkpoint | None: ... + def list(self) -> list[Checkpoint]: ... + def delete(self, checkpoint_id: str) -> None: ... + + +_UNSAFE = re.compile(r"[^A-Za-z0-9_-]") + + +class FileCheckpointStore: + """One ``.json`` per receipt id under *directory*; mode 0600; atomic rename. + + ``list()`` returns oldest-first by mtime. ``delete()`` of a missing id is a + no-op. Ids are sanitized for the filesystem; the stored ``receiptId`` keeps + the original. + """ + + def __init__(self, directory: Path | str) -> None: + self.directory = Path(directory) + self.directory.mkdir(parents=True, exist_ok=True) + + def _path(self, checkpoint_id: str) -> Path: + safe = _UNSAFE.sub("_", checkpoint_id) or "_" + return self.directory / f"{safe}.json" + + def save(self, checkpoint: Checkpoint) -> None: + target = self._path(checkpoint.id) + fd, tmp = tempfile.mkstemp(dir=self.directory, prefix=".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(checkpoint.to_json()) + fh.write("\n") + os.chmod(tmp, 0o600) + os.replace(tmp, target) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + def load(self, checkpoint_id: str) -> Checkpoint | None: + path = self._path(checkpoint_id) + if not path.exists(): + return None + return Checkpoint.from_json(path.read_text(encoding="utf-8")) + + def list(self) -> list[Checkpoint]: + paths = sorted((p for p in self.directory.glob("*.json") if not p.name.startswith(".")), + key=lambda p: (p.stat().st_mtime_ns, p.name)) + return [Checkpoint.from_json(p.read_text(encoding="utf-8")) for p in paths] + + def delete(self, checkpoint_id: str) -> None: + try: + self._path(checkpoint_id).unlink() + except FileNotFoundError: + pass + + +__all__ = ["Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint"] diff --git a/bridge-sdk/python/aleo_bridge/circle.py b/bridge-sdk/python/aleo_bridge/circle.py new file mode 100644 index 0000000..be07c4c --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/circle.py @@ -0,0 +1,57 @@ +"""Circle xReserve attester — read-only HTTP (port of veil ``getAttestation``). Never signs or moves funds.""" +from __future__ import annotations + +from typing import Any + +import requests + +from . import encoding as enc +from ._keccak import keccak256 +from .errors import AttestationError, ConfigurationError, InvalidRecipientError +from .types import Attestation + + +class CircleClient: + """``GET {base_url}/{messageHash}``: 404 → ``None`` (pending); 200 → a verified :class:`Attestation`.""" + + def __init__(self, base_url: str, session: Any = None, timeout: float = 30.0) -> None: + if not isinstance(base_url, str) or not base_url.startswith("https://"): + raise ConfigurationError(f"Circle attestation base URL must start with https://, got {base_url!r}") + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = session if session is not None else requests.Session() + + def get_attestation(self, message_hash_hex: str) -> Attestation | None: + try: + digest = enc.hex_to_bytes(message_hash_hex, 32) + except (ValueError, InvalidRecipientError) as exc: + raise AttestationError(f"Circle attestation lookup needs a 32-byte message hash, got {message_hash_hex!r}") from exc + try: + response = self._session.get(f"{self.base_url}/{enc.to_hex(digest)}", timeout=self.timeout) + except requests.exceptions.RequestException as exc: + raise AttestationError(f"Circle attester request failed: {exc}") from exc + if response.status_code == 404: + return None + if response.status_code != 200: + raise AttestationError(f"Circle attester request failed with HTTP {response.status_code}") + try: + body = response.json() + except ValueError as exc: + raise AttestationError(f"Circle attester request failed: {exc}") from exc + value = body.get("attestation") if isinstance(body, dict) else None + if not isinstance(value, dict): + raise AttestationError("Circle attester returned an invalid response (no attestation object)") + try: + payload = enc.hex_to_bytes(value["payload"], enc.XRESERVE_PAYLOAD_BYTES) + signature = enc.hex_to_bytes(value["attestation"], enc.HOOK_DATA_BYTES) + echoed = enc.hex_to_bytes(value["messageHash"], 32) + except (KeyError, TypeError, ValueError, InvalidRecipientError) as exc: + raise AttestationError("Circle attester returned an invalid response (payload/attestation/messageHash)") from exc + if echoed != digest: + raise AttestationError("Circle attester echoed a different message hash than requested") + if keccak256(payload) != digest: + raise AttestationError("Circle attestation payload does not match the requested message hash") + return Attestation(payload=payload, message_hash=digest, attestation=signature, status="complete") + + +__all__ = ["CircleClient"] diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py new file mode 100644 index 0000000..479b041 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -0,0 +1,437 @@ +"""Bridge — the web3.py-style client for moving assets to and from Aleo. + + bridge = Bridge(aleo) # Aleo legs: hyperlane / xreserve / privacy / freezelist + bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY, ALEO_ENDPOINT, ALEO_NETWORK, … + bridge = Bridge.from_profile() # $ALEO_BRIDGE_HOME or ~/.aleo-bridge + +Reads return values; Aleo writes return an AleoCall — nothing touches the network until a verb runs. +Ethereum/Solana connections (plans 2/3), lifecycle verbs and checkpoints (plan 4) plug into the +constructor parameters reserved here. +""" +from __future__ import annotations + +import os +import re +from typing import TYPE_CHECKING, Any, Callable + +from . import lifecycle as _lifecycle +from ._calls import AleoCall +from .errors import BridgeError, ConfigurationError +from .eth import Ethereum, EthModule +from .freezelist import FreezeList +from .hyperlane import HyperlaneModule +from .privacy import PrivacyModule +from .profile import DEFAULT_ENDPOINT, Profile +from .registry import DEFAULT_REGISTRY, Asset, Chain, Registry, validate_registry +from .sol import Solana, SolModule +from .types import BridgeStatus, ChainStatus, PrivacyReceipt +from .units import format_decimal_amount, parse_decimal_amount +from .xreserve import XReserveModule + +if TYPE_CHECKING: # pragma: no cover + from .types import Progress + +NETWORKS = ("mainnet", "testnet") +BALANCE_MAPPING = "balances" +_UINT_LITERAL_RE = re.compile(r"^(\d+)u\d+$") + + +def parse_uint_literal(value: str) -> int: + """``"2392443u64"`` → 2392443 (any ``uN`` suffix).""" + match = _UINT_LITERAL_RE.match(value.strip()) + if not match: + raise ConfigurationError(f"Expected an unsigned Aleo integer literal, got {value!r}") + return int(match.group(1)) + + +def balance_program(asset: Asset) -> str | None: + """Program whose ``balances`` mapping holds *asset*'s public balance (None for ALEO credits / no locator).""" + # Verified on chain 2026-09-17: the warp/xreserve programs mint/burn via arc20_.aleo's + # mint_public/burn_public, so this IS the ledger transfer_remote (and xreserve burns) spend. + if asset.locator is None or asset.locator.kind != "aleo-program" or asset.locator.value == "credits.aleo": + return None + return asset.privacy.program if asset.privacy is not None else asset.locator.value + + +def build_aleo(endpoint: str, network: str, private_key: str, *, api_key: str | None = None, + consumer_id: str | None = None) -> Any: + """An ``aleo.Aleo`` facade bound to *endpoint*/*network* with *private_key* as the default account (local only).""" + from aleo import Aleo, HTTPProvider + kwargs: dict[str, Any] = {"network": network} + if api_key: + kwargs["api_key"] = api_key + if consumer_id: + kwargs["consumer_id"] = consumer_id + aleo = Aleo(HTTPProvider(endpoint, **kwargs)) + aleo.default_account = aleo.account.from_private_key(private_key) + return aleo + + +def ethereum_from_env() -> Any: + """``Ethereum(ETHEREUM_RPC_URL, private_key=EVM_PRIVATE_KEY)`` or None; both variables or neither.""" + return Ethereum.from_env() + + +def _coerce_ethereum(value: Any) -> Ethereum | None: + """Accept an ``Ethereum`` or a bare ``web3.Web3`` (wrapped; signs only via ``eth.default_account``).""" + if value is None or isinstance(value, Ethereum): + return value + if hasattr(value, "eth") and hasattr(value, "provider"): + return Ethereum(w3=value) + raise ConfigurationError("ethereum= must be an aleo_bridge.Ethereum connection or a web3.Web3 instance") + + +def _coerce_solana(value: Any) -> Solana | None: + """Accept a ``Solana``, an RPC URL string, or a bare solana-py ``Client`` (wrapped read-only). + + Mirrors :func:`_coerce_ethereum`: anything else is a configuration mistake caught here rather + than as an ``AttributeError`` from the first RPC read. + """ + if value is None or isinstance(value, Solana): + return value + if isinstance(value, str): + return Solana(rpc_url=value) + if callable(getattr(value, "get_latest_blockhash", None)) and callable(getattr(value, "get_account_info", None)): + return Solana(client=value) # spec §3: a bare client is a read-only connection + raise ConfigurationError( + "solana= must be an aleo_bridge.Solana connection, an RPC URL string, or a solana-py Client " + "(an object with get_latest_blockhash and get_account_info)") + + +def solana_from_env() -> Any: + """``Solana.from_env()``: SOLANA_PRIVATE_KEY/BRIDGE_SOLANA_PRIVATE_KEY (+ SOLANA_RPC_URL/ + BRIDGE_LIVE_SOLANA_RPC_URL) or None; a key alone signs, a URL alone is read-only, neither → None.""" + return Solana.from_env() + + +def checkpoints_from_env() -> Any: + """``FileCheckpointStore(BRIDGE_CHECKPOINT_DIR)`` or None.""" + directory = os.environ.get("BRIDGE_CHECKPOINT_DIR") + if not directory: + return None + from .checkpoint import FileCheckpointStore + return FileCheckpointStore(directory) + + +def _checkpoints_for_profile(profile: Profile) -> Any: + from .checkpoint import FileCheckpointStore + return FileCheckpointStore(profile.checkpoint_dir) + + +class Bridge: + """Typed bridge client over the Aleo facade (and, from plans 2/3, Ethereum/Solana connections).""" + + def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, environment: str | None = None, + registry: Registry | None = None, checkpoints: Any = None) -> None: + network = getattr(aleo, "network_name", None) + if network not in NETWORKS: + raise ConfigurationError(f"The aleo facade must report network_name mainnet or testnet, got {network!r}") + environment = environment if environment is not None else network + if environment not in NETWORKS: + raise ConfigurationError(f"environment must be mainnet or testnet, got {environment!r}") + if environment != network: + raise ConfigurationError(f"Bridge environment {environment!r} does not match the facade network {network!r}") + self.aleo = aleo + self.environment: str = environment + self.network: str = network + self.registry: Registry = validate_registry(registry if registry is not None else DEFAULT_REGISTRY) + if not self.registry.chains(environment=environment): + raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") + self.checkpoints = checkpoints + self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) + self.solana: Solana | None = _coerce_solana(solana) + solana = self.solana + self._sol: SolModule | None = SolModule(self, solana) if solana is not None else None + self.profile: Profile | None = None + self._eth: EthModule | None = None + self._programs: dict[str, Any] = {} + self.hyperlane = HyperlaneModule(self) + self.xreserve = XReserveModule(self) + self.freezelist = FreezeList(self) + self.privacy = PrivacyModule(self) + + def __repr__(self) -> str: + return f"Bridge(environment={self.environment!r}, registry={self.registry.version!r})" + + # ── side-chain namespaces (plan 3 supplies the Solana module) ── + @property + def eth(self) -> EthModule: + """Ethereum-origin actions (Hyperlane transferRemote, xReserve deposit, status, recovery).""" + if self.ethereum is None: + raise ConfigurationError( + "Pass ethereum=Ethereum(...) to Bridge(...) or set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL") + if self._eth is None: + self._eth = EthModule(self, self.ethereum) + return self._eth + + @property + def sol(self) -> SolModule: + """Solana-origin module (spec §6). Requires a Solana connection.""" + if self._sol is None: + raise ConfigurationError( + "Solana is not configured: pass solana=Solana(rpc_url, private_key=...) or a solana-py Client to Bridge(), " + "or set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) for Bridge.from_env()") + return self._sol + + # ── identity / registry helpers ── + def aleo_chain(self) -> Chain: + chains = [c for c in self.registry.chains(environment=self.environment) if c.family == "aleo"] + if len(chains) != 1: + raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self.environment}") + return chains[0] + + def solana_chain(self) -> Chain | None: + """The environment's Solana chain, or None — only mainnet has one.""" + chains = [c for c in self.registry.chains(environment=self.environment) if c.family == "solana"] + return chains[0] if chains else None + + def aleo_address(self) -> str: + account = getattr(self.aleo, "default_account", None) + if not account: + raise ConfigurationError("aleo.default_account is not set; assign aleo.account.from_private_key(...) first") + return str(account.address) + + def to_atomic(self, amount: Any, asset: Any) -> int: + return parse_decimal_amount(amount, self.registry.asset(asset).decimals) + + def from_atomic(self, atomic: int, asset: Any) -> str: + return format_decimal_amount(atomic, self.registry.asset(asset).decimals) + + # ── facade seams used by every module ── + def program(self, program_id: str) -> Any: + """Facade ``Program`` for *program_id*, fetched once per client.""" + if program_id not in self._programs: + self._programs[program_id] = self.aleo.programs.get(program_id) + return self._programs[program_id] + + def mapping_value(self, program_id: str, mapping: str, key: str) -> str | None: + """Mapping value as a string, or None when the key is absent/null, or *program_id* does not exist. + + A missing program is reported through the return value here, not an exception, so status()/freezelist + reads over a program that may not be deployed (yet) degrade to "no data" instead of raising. Callers + that want the error can still get it from ``program(program_id)`` directly. + """ + from aleo.facade.errors import ProgramNotFound + try: + value = self.program(program_id).mapping(mapping).get(key) + except ProgramNotFound: + return None + if value is None: + return None + text = str(value).strip().strip('"') + return None if text in ("", "null", "None") else text + + def _import_sources(self, program_id: str) -> dict[str, str]: + """``{program_id: source}`` for every transitive import (dependencies first) and the root last.""" + ordered: dict[str, str] = {} + + def visit(pid: str) -> None: + if pid in ordered: + return + program = self.program(pid) + for dep in program.imports: + visit(str(dep)) + ordered[pid] = str(program.source) + + visit(program_id) + return ordered + + def _call(self, program_id: str, function: str, inputs: list[str], build_result: Callable[[str, list[str]], Any]) -> AleoCall: + bound = self.program(program_id).functions[function](*inputs) + return AleoCall(self.aleo, bound, build_result, imports=self._import_sources(program_id)) + + # ── privacy shortcuts ── + def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + 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]: + return self.privacy.unshield(asset, amount=amount, amount_atomic=amount_atomic, record=record, + merkle_proof=merkle_proof, recipient=recipient) + + # ── status ── + def _public_balance(self, asset: Asset, address: str) -> int: + if asset.locator is not None and asset.locator.value == "credits.aleo": + value = self.mapping_value("credits.aleo", "account", address) + else: + program = balance_program(asset) + if program is None: + return 0 + value = self.mapping_value(program, BALANCE_MAPPING, address) + return parse_uint_literal(value) if value is not None else 0 + + def _aleo_chain_status(self) -> ChainStatus: + chain = self.aleo_chain() + account = getattr(self.aleo, "default_account", None) + address = str(account.address) if account else None + balances = {asset.id: (self._public_balance(asset, address) if address else 0) + for asset in self.registry.assets(chain=chain.id)} + return ChainStatus(chain.id, address, address is not None, balances) + + def status(self) -> BridgeStatus: + """Read-only re-orientation: addresses and public balances of every registry asset per configured chain. + Plan 4 fills ``pending`` from the checkpoint store.""" + chains = [self._aleo_chain_status()] + if self.ethereum is not None: + chains.append(self.eth.chain_status()) + solana_chain = self.solana_chain() + if self.solana is not None and solana_chain is not None: + # Chain id and asset id come from the registry, not literals: a testnet client (no Solana + # chain at all) reports no Solana row rather than one naming a chain this environment lacks. + native = next((a for a in self.registry.assets(chain=solana_chain.id) if a.kind == "native"), None) + balances = ({native.id: self.sol.balance()} + if native is not None and self.solana.address is not None else {}) + chains.append(ChainStatus(chain_id=solana_chain.id, address=self.solana.address, + can_sign=self.solana.can_sign, balances=balances)) + pending: list["Progress"] = [] + return BridgeStatus(environment=self.environment, registry_version=self.registry.version, + chains=chains, pending=pending) + + # ── Tier 1: the lifecycle ────────────────────────────────────────────── + + def quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: str, + sender: str | None = None, protocol: str | None = None, mint_mode: str = "public", + secret_nonce: str = "0scalar"): + """Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + + ``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` + tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of + ``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is + the destination-chain address. ``mint_mode`` (xReserve into Aleo only): + ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` + — you finish it yourself with ``complete`` and must keep ``secret_nonce``. + Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / + 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``. + """ + return _lifecycle.quote(self, source=source, destination=destination, amount=amount, + amount_atomic=amount_atomic, recipient=recipient, sender=sender, + protocol=protocol, mint_mode=mint_mode, secret_nonce=secret_nonce) + + def 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): + """Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + + Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to + ``on_checkpoint`` (and the bound store) at every boundary — including + AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is + resumable without proving twice. ``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 + 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, + gas_payment_microcredits=gas_payment_microcredits, secret_nonce=secret_nonce, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) + + def get_status(self, plan, receipt): + """One status refresh (no polling, no signing); returns the same receipt when nothing changed.""" + return _lifecycle.get_status(self, plan, receipt) + + def wait(self, progress, *, until=None, poll_seconds: float = 15.0, timeout_seconds: float = 1200.0, + on_update=None, on_error=None, max_consecutive_errors: int = 5): + """Poll until the transfer finishes or needs you: stops at ``progress.next`` + in resume / complete / done / failed, or at any status in ``until``. + + A ``PollingTimeoutError`` is NOT a failure — the transfer is still in + flight; call ``wait`` again or ``recover`` later. ``on_update`` receives + each changed ``Progress``. A transient error (flaky RPC/HTTP transport) + is retried up to ``max_consecutive_errors`` times, calling ``on_error`` + on each tolerated retry; a non-transient error propagates immediately. + """ + return _lifecycle.wait(self, progress, until=until, poll_seconds=poll_seconds, + timeout_seconds=timeout_seconds, on_update=on_update, on_error=on_error, + max_consecutive_errors=max_consecutive_errors) + + def recover(self, checkpoint): + """Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + + Re-resolves the route from the live registry and reads chain state once; + ``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, + ``done`` or ``failed``. + """ + return _lifecycle.recover(self, checkpoint) + + def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = None, + poll_seconds: float = 1.0, timeout_seconds: float = 120.0, proving: str = "delegate"): + """Finish an interrupted source submission (``progress.next == "resume"``). + + Rebroadcasts the identical proved Aleo transaction (a duplicate response is + success) or, on EVM, re-scans history and only then authorizes the single + missing deposit/dispatch. Never repeats a confirmed step. + """ + return _lifecycle.resume(self, progress, on_checkpoint=on_checkpoint, secret_nonce=secret_nonce, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, proving=proving) + + def complete(self, progress, *, secret_nonce: str, on_checkpoint=None, proving: str = "delegate"): + """Submit the private USDCx mint (``progress.next == "complete"``). + + Requires the same ``secret_nonce`` given to ``execute``; the SDK never + stored it. Submits exactly one ``private_mint`` and returns + ``DESTINATION_CONFIRMING`` progress to ``wait`` on. + """ + return _lifecycle.complete(self, progress, secret_nonce=secret_nonce, on_checkpoint=on_checkpoint, + proving=proving) + + def pending(self) -> list: + """The in-flight transfers of this profile — every checkpoint in the bound store, + reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one + unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` + with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` + on any entry to refresh it against live chain state. + """ + store = self.checkpoints + if store is None: + return [] + out = [] + for cp in store.list(): + try: + out.append(_lifecycle.progress_from_checkpoint(self.registry, cp)) + except BridgeError: + continue # no Plan could be rebuilt at all (bad format/version/route) — nothing to report + return out + + # ── constructors ── + @classmethod + def from_env(cls, **overrides: Any) -> "Bridge": + """Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints.""" + unexpected = set(overrides) - {"ethereum", "solana", "registry", "checkpoints"} + if unexpected: + raise TypeError(f"Bridge.from_env() got unexpected overrides: {sorted(unexpected)}") + private_key = os.environ.get("BRIDGE_PRIVATE_KEY") + if not private_key: + raise ConfigurationError("BRIDGE_PRIVATE_KEY is required (an APrivateKey1... string)") + aleo = build_aleo(os.environ.get("ALEO_ENDPOINT", DEFAULT_ENDPOINT), os.environ.get("ALEO_NETWORK", "mainnet"), + private_key, api_key=os.environ.get("ALEO_API_KEY"), consumer_id=os.environ.get("ALEO_CONSUMER_ID")) + ethereum = overrides["ethereum"] if "ethereum" in overrides else ethereum_from_env() + solana = overrides["solana"] if "solana" in overrides else solana_from_env() + checkpoints = overrides["checkpoints"] if "checkpoints" in overrides else checkpoints_from_env() + return cls(aleo, ethereum=ethereum, solana=solana, registry=overrides.get("registry"), checkpoints=checkpoints) + + @classmethod + def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: str | None = None, + ethereum: Any = None, solana: Any = None) -> "Bridge": + """The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when + creating. Side-chain connections come from the arguments or the same env variables as ``from_env``.""" + kwargs = {k: v for k, v in (("network", network), ("endpoint", endpoint)) if v is not None} + profile = Profile.load_or_create(home, **kwargs) + aleo = build_aleo(profile.endpoint, profile.network, profile.private_key, + api_key=os.environ.get("ALEO_API_KEY"), consumer_id=os.environ.get("ALEO_CONSUMER_ID")) + bridge = cls(aleo, ethereum=ethereum if ethereum is not None else ethereum_from_env(), + solana=solana if solana is not None else solana_from_env(), + checkpoints=_checkpoints_for_profile(profile)) + bridge.profile = profile + return bridge + + +__all__ = ["BALANCE_MAPPING", "Bridge", "balance_program", "build_aleo", "checkpoints_from_env", "ethereum_from_env", + "parse_uint_literal", "solana_from_env"] diff --git a/bridge-sdk/python/aleo_bridge/encoding.py b/bridge-sdk/python/aleo_bridge/encoding.py new file mode 100644 index 0000000..725322a --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/encoding.py @@ -0,0 +1,289 @@ +"""Pure wire encoders shared by every route family (port of veil utils/xreserve.ts, hyperlane.ts, +hyperlaneDelivery.ts). Nothing here touches the network; only ``xreserve_hook_data`` (private mode) +and ``aleo_program_address`` load the ``aleo.`` bindings. + +Spacing rules (invariant 8): ``u8_array_literal`` joins with ``","`` and NO space, every other +struct/array literal joins with ``", "``. +""" +from __future__ import annotations + +import re +from typing import Any + +from ._base58 import b58decode +from ._keccak import keccak256 +from .errors import AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError + +BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2BC830A3 +_BECH32_GENERATORS = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) +_HRP = "aleo" +_EVM_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +_SCALAR_RE = re.compile(r"^(0|[1-9][0-9]*)scalar$") +NETWORKS = ("mainnet", "testnet") +MINT_MODES = ("public", "record", "private") +HOOK_DATA_BYTES = 65 +XRESERVE_PAYLOAD_BYTES = 305 +_PAYLOAD_HEADER = bytes.fromhex("5a2e0acd00000001") +_HOOK_LENGTH_FIELD = bytes.fromhex("00000041") + + +# ── Generic byte helpers ────────────────────────────────────────────────────── + +def hex_to_bytes(value: "str | bytes | bytearray | memoryview", expected_len: "int | None" = None) -> bytes: + """``0x``-prefixed (or bare) hex, or bytes, to ``bytes``; optionally assert the width.""" + if isinstance(value, str): + text = value[2:] if value[:2] in ("0x", "0X") else value + try: + data = bytes.fromhex(text) + except ValueError as exc: + raise InvalidRecipientError(f"Not hexadecimal: {value!r}") from exc + else: + data = bytes(value) + if expected_len is not None and len(data) != expected_len: + raise InvalidRecipientError(f"Expected {expected_len} bytes, got {len(data)}") + return data + + +def to_hex(data: "bytes | bytearray | memoryview") -> str: + return "0x" + bytes(data).hex() + + +def network_module(network: str) -> Any: + """``aleo.mainnet`` / ``aleo.testnet`` for *network*; ``ConfigurationError`` otherwise.""" + if network not in NETWORKS: + raise ConfigurationError(f"network must be one of {NETWORKS}, got {network!r}") + import aleo # noqa: WPS433 — the bindings are a runtime dependency, imported lazily + return getattr(aleo, network) + + +def field_bytes_le(value: Any) -> bytes: + """``Field/Address.to_bytes_le()`` returns ``list[int]`` at runtime; normalise to bytes.""" + return bytes(value.to_bytes_le()) + + +def validate_scalar(secret_nonce: str) -> str: + if not isinstance(secret_nonce, str) or not _SCALAR_RE.match(secret_nonce): + raise ConfigurationError( + f"secret_nonce must be a non-negative Aleo scalar literal such as 0scalar, got {secret_nonce!r}") + return secret_nonce + + +# ── Aleo bech32m ────────────────────────────────────────────────────────────── + +def _bech32_polymod(values: list[int]) -> int: + chk = 1 + for v in values: + top = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ v + for i in range(5): + if (top >> i) & 1: + chk ^= _BECH32_GENERATORS[i] + return chk + + +def _hrp_expand() -> list[int]: + return [ord(c) >> 5 for c in _HRP] + [0] + [ord(c) & 31 for c in _HRP] + + +def aleo_address_to_bytes32(address: str) -> bytes: + """Decode a checksummed ``aleo1…`` bech32m address into its 32 payload bytes (xReserve/Hyperlane form).""" + try: + if not isinstance(address, str) or not address.startswith("aleo1") or len(address) != 63: + raise ValueError("invalid prefix or length") + words = [BECH32_ALPHABET.index(c) for c in address[5:]] # ValueError on a foreign character + if _bech32_polymod(_hrp_expand() + words) != BECH32M_CONST: + raise ValueError("invalid checksum") + acc = bits = 0 + out = bytearray() + for word in words[:-6]: + acc = (acc << 5) | word + bits += 5 + while bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + if bits >= 5 or ((acc << (8 - bits)) & 0xFF) != 0: + raise ValueError("invalid padding") + if len(out) != 32: + raise ValueError("invalid payload") + return bytes(out) + except ValueError as exc: + raise InvalidRecipientError(f"Invalid Aleo recipient address: {address}") from exc + + +def bytes32_to_aleo_address(data: bytes) -> str: + """Inverse of :func:`aleo_address_to_bytes32` — re-encode 32 bytes as a checksummed ``aleo1…`` address.""" + raw = bytes(data) + if len(raw) != 32: + raise InvalidRecipientError(f"Invalid 32-byte Aleo recipient: {to_hex(raw)}") + acc = bits = 0 + words: list[int] = [] + for byte in raw: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + words.append((acc >> bits) & 31) + if bits: + words.append((acc << (5 - bits)) & 31) + checksum = _bech32_polymod(_hrp_expand() + words + [0] * 6) ^ BECH32M_CONST + words += [(checksum >> (5 * (5 - i))) & 31 for i in range(6)] + return _HRP + "1" + "".join(BECH32_ALPHABET[w] for w in words) + + +# ── EVM addresses ───────────────────────────────────────────────────────────── + +def to_checksum_address(address: str) -> str: + """EIP-55 checksum form of a 20-byte hex address.""" + body = address[2:].lower() + digest = keccak256(body.encode()).hex() + return "0x" + "".join(c.upper() if int(digest[i], 16) >= 8 else c for i, c in enumerate(body)) + + +def is_evm_address(value: Any) -> bool: + """20-byte hex; mixed case must be a valid EIP-55 checksum (viem ``isAddress`` semantics).""" + if not isinstance(value, str) or not _EVM_ADDRESS_RE.match(value): + return False + body = value[2:] + if body == body.lower() or body == body.upper(): + return True + return to_checksum_address(value) == value + + +def evm_address_to_bytes32(address: str) -> bytes: + """Left-pad a checksum-validated EVM address to 32 bytes (xReserve burn recipient / Hyperlane bytes32).""" + if not is_evm_address(address): + raise InvalidRecipientError(f"Invalid Ethereum recipient address: {address}") + return bytes(12) + bytes.fromhex(address[2:]) + + +# ── Hyperlane recipient limbs and Aleo literals ─────────────────────────────── + +def bytes32_to_u128_limbs(data: bytes) -> tuple[int, int]: + """Two little-endian u128 limbs over ``bytes[0:16]`` and ``bytes[16:32]``.""" + raw = bytes(data) + if len(raw) != 32: + raise InvalidRecipientError(f"Hyperlane recipient limbs need exactly 32 bytes, got {len(raw)}") + return int.from_bytes(raw[:16], "little"), int.from_bytes(raw[16:], "little") + + +def evm_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: + if not is_evm_address(address): + raise InvalidRecipientError(f"Invalid Ethereum Hyperlane recipient: {address}") + return bytes32_to_u128_limbs(evm_address_to_bytes32(address)) + + +def solana_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: + try: + raw = b58decode(address) + except ValueError as exc: + raise InvalidRecipientError(f"Invalid Solana Hyperlane recipient: {address}") from exc + if len(raw) != 32: + raise InvalidRecipientError(f"Invalid Solana Hyperlane recipient: {address}") + return bytes32_to_u128_limbs(raw) + + +def u128_pair_literal(limbs: tuple[int, int]) -> str: + """``[{lo}u128, {hi}u128]`` — the Warp Route recipient input (space after the comma).""" + lo, hi = limbs + return f"[{lo}u128, {hi}u128]" + + +def u8_array_literal(data: bytes) -> str: + """``[0u8,255u8]`` — veil ``xReserveHexToAleoBytes``: NO space after the comma.""" + return "[" + ",".join(f"{b}u8" for b in bytes(data)) + "]" + + +def hyperlane_delivery_key(message_id: bytes) -> str: + """``hyp_mailbox.aleo/deliveries`` key: ``{ id: [{lo}u128, {hi}u128] }``.""" + lo, hi = bytes32_to_u128_limbs(hex_to_bytes(message_id, 32)) + return f"{{ id: [{lo}u128, {hi}u128] }}" + + +# ── Circle xReserve ─────────────────────────────────────────────────────────── + +def _uint_be(value: int, width: int) -> bytes: + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value >= 1 << (8 * width): + raise InvalidAmountError(f"Unsigned value does not fit in {width} bytes: {value!r}") + return value.to_bytes(width, "big") + + +def xreserve_deposit_nonce(source_domain: int, tx_hash: "bytes | str", log_index: int) -> bytes: + """Circle's deposit nonce: ``keccak(abi.encode(uint32 domain) ‖ txHash ‖ abi.encode(uint256 logIndex))``.""" + _uint_be(source_domain, 4) # bound to uint32, consistent with the payload's remote_domain check below + return keccak256(_uint_be(source_domain, 32) + hex_to_bytes(tx_hash, 32) + _uint_be(log_index, 32)) + + +def xreserve_deposit_payload(*, amount: int, remote_domain: int, remote_token: bytes, remote_recipient: bytes, + local_token: str, depositor: str, max_fee: int, nonce: bytes, + hook_data: bytes) -> bytes: + """The canonical 305-byte xReserve v2 deposit payload Circle signs. + + header[0..8) amount[8..40) domain[40..44) remoteToken[44..76) recipient[76..108) + localToken[108..140) depositor[140..172) maxFee[172..204) nonce[204..236) hookLen[236..240) hook[240..305) + """ + for name, value, width in (("remote_token", remote_token, 32), ("remote_recipient", remote_recipient, 32), + ("nonce", nonce, 32), ("hook_data", hook_data, HOOK_DATA_BYTES)): + if len(bytes(value)) != width: + raise InvalidRecipientError(f"{name} must contain {width} bytes") + out = bytearray(XRESERVE_PAYLOAD_BYTES) + out[0:8] = _PAYLOAD_HEADER + out[8:40] = _uint_be(amount, 32) + out[40:44] = _uint_be(remote_domain, 4) + out[44:76] = bytes(remote_token) + out[76:108] = bytes(remote_recipient) + out[108:140] = evm_address_to_bytes32(local_token) + out[140:172] = evm_address_to_bytes32(depositor) + out[172:204] = _uint_be(max_fee, 32) + out[204:236] = bytes(nonce) + out[236:240] = _HOOK_LENGTH_FIELD + out[240:305] = bytes(hook_data) + return bytes(out) + + +def xreserve_message_hash(payload: bytes) -> bytes: + """Circle's attestation lookup key: ``keccak256(payload)``.""" + return keccak256(bytes(payload)) + + +def xreserve_nonce_from_payload(payload: bytes) -> bytes: + """Deposit nonce (bytes 204..236) from a canonical payload; validates header, width and hook length.""" + raw = bytes(payload) + if len(raw) != XRESERVE_PAYLOAD_BYTES or raw[0:8] != _PAYLOAD_HEADER or raw[236:240] != _HOOK_LENGTH_FIELD: + raise AttestationError("xReserve payload has an invalid deposit layout") + return raw[204:236] + + +def xreserve_hook_data(mode: str, recipient: str, network: str, secret_nonce: str = "0scalar") -> bytes: + """65-byte xReserve hook: byte 0 selects the mint transition (0 public, 1 record, 2 private); + private mode carries ``BHP256.commit(bits(recipient), secret_nonce)`` in bytes 1..33.""" + if mode not in MINT_MODES: + raise ConfigurationError(f"Unsupported mint mode {mode!r}; expected one of {MINT_MODES}") + aleo_address_to_bytes32(recipient) # InvalidRecipientError before any FFI work + out = bytearray(HOOK_DATA_BYTES) + out[0] = MINT_MODES.index(mode) + if mode == "private": + validate_scalar(secret_nonce) + net = network_module(network) + bits = net.Plaintext.from_string(recipient).to_bits_le() + commitment = field_bytes_le(net.BHP256().commit(bits, net.Scalar.from_string(secret_nonce))) + if len(commitment) != 32: + raise ConfigurationError("Private mint commitment must contain 32 bytes") + out[1:33] = commitment + return bytes(out) + + +def aleo_program_address(program_id: str, network: str) -> str: + """The ``aleo1…`` account owned by a deployed program (private xReserve deposits target the wrapper's).""" + return str(network_module(network).Address.from_program_id(program_id)) + + +__all__ = [ + "BECH32_ALPHABET", "HOOK_DATA_BYTES", "MINT_MODES", "NETWORKS", "XRESERVE_PAYLOAD_BYTES", + "aleo_address_to_bytes32", "aleo_program_address", "bytes32_to_aleo_address", "bytes32_to_u128_limbs", + "evm_address_to_bytes32", "evm_address_to_hyperlane_recipient", "field_bytes_le", "hex_to_bytes", + "hyperlane_delivery_key", "is_evm_address", "network_module", "solana_address_to_hyperlane_recipient", + "to_checksum_address", "to_hex", "u128_pair_literal", "u8_array_literal", "validate_scalar", + "xreserve_deposit_nonce", "xreserve_deposit_payload", "xreserve_hook_data", "xreserve_message_hash", + "xreserve_nonce_from_payload", +] diff --git a/bridge-sdk/python/aleo_bridge/errors.py b/bridge-sdk/python/aleo_bridge/errors.py new file mode 100644 index 0000000..6252db0 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/errors.py @@ -0,0 +1,93 @@ +"""Error taxonomy — every failure raised by aleo_bridge is a BridgeError whose message states the remedy.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover + from .types import Progress, Status + + +class BridgeError(Exception): + """Base class for every error raised by aleo_bridge.""" + + +class ConfigurationError(BridgeError): + """The client, environment, or registry is configured inconsistently.""" + + +class MissingExtraError(BridgeError): + """An optional dependency group is required for this feature.""" + + def __init__(self, extra: str, feature: str) -> None: + self.extra = extra + self.feature = feature + super().__init__(f"{feature} requires the '{extra}' extra: pip install 'aleo-bridge-sdk[{extra}]'") + + +class RouteNotFoundError(BridgeError): + """No registry entry matches the lookup (route, asset, or chain).""" + + +class AmbiguousRouteError(BridgeError): + """More than one route matches; pass protocol= to disambiguate.""" + + +class RouteUnavailableError(BridgeError): + """The route exists but is metadata-required, disabled, or carries placeholder configuration.""" + + +class RegistryVersionMismatchError(BridgeError): + """A plan or checkpoint was prepared against a different registry version.""" + + +class UnsupportedRouteError(BridgeError): + """No implementation exists for this (protocol, chain family) combination or asset capability.""" + + +class InvalidAmountError(BridgeError): + """The amount is malformed, too precise, zero, or below the route minimum.""" + + +class InvalidRecipientError(BridgeError): + """The recipient does not match the destination chain's address format.""" + + +class InsufficientBalanceError(BridgeError): + """The account cannot cover the amount (public balance or no covering record).""" + + +class ChainMismatchError(BridgeError): + """The connected EVM chain id / Solana genesis does not match the route.""" + + +class NotResumableError(BridgeError): + """The progress is not in a resumable or completable state.""" + + +class CheckpointInvalidError(BridgeError): + """A checkpoint fails the version-1 allowlist or does not match its plan.""" + + +class AttestationError(BridgeError): + """Circle's response is malformed, does not hash to the requested message, or the secret does not open the commitment.""" + + +class DeliveryUnknownError(BridgeError): + """Delivery could not be determined from the destination chain.""" + + +class PollingTimeoutError(BridgeError): + """``wait`` gave up; carries the last observed status and progress (timeout is not failure).""" + + def __init__(self, message: str, *, status: "Status | str", progress: "Progress | None" = None) -> None: + self.status: Any = status + self.progress = progress + super().__init__(message) + + +__all__ = [ + "BridgeError", "ConfigurationError", "MissingExtraError", "RouteNotFoundError", "AmbiguousRouteError", + "RouteUnavailableError", "RegistryVersionMismatchError", "UnsupportedRouteError", "InvalidAmountError", + "InvalidRecipientError", "InsufficientBalanceError", "ChainMismatchError", "NotResumableError", + "CheckpointInvalidError", "AttestationError", "DeliveryUnknownError", "PollingTimeoutError", +] diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py new file mode 100644 index 0000000..a5df096 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -0,0 +1,1440 @@ +"""Ethereum connection and the ``bridge.eth`` module (Hyperlane + xReserve, Ethereum origin). + +``web3`` and ``eth_account`` are imported lazily so ``import aleo_bridge`` works +without the ``evm`` extra; the first call that needs them raises +``MissingExtraError("evm", ...)``. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, fields +from typing import Any, Mapping + +from . import encoding +from ._calls import EvmCall, EvmOutcome, EvmStep +from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, XRESERVE_ABI, ZERO_ADDRESS +from ._plan import build_plan as _plan_for # kept as a module-level name: existing callers import eth._plan_for +from .checkpoint import Checkpoint +from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, + InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, + RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) +from .registry import Asset, Chain, Registry, Route +from .types import (ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, + Receipt, Status) +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount + + +def _web3(): + try: + import web3 + except ImportError as exc: # pragma: no cover - exercised by test_import_without_web3 + raise MissingExtraError("evm", "Ethereum connections") from exc + return web3 + + +_HASH_RE = re.compile(r"^0x[0-9a-fA-F]{64}$") + +LOG_SCAN_CHUNK_BLOCKS = 5_000 +"""Default block span per ``eth_getLogs`` request during recovery scans. + +Public RPC endpoints cap the range (and the result size) of a single ``eth_getLogs``; an +unbounded ``{"fromBlock": n}`` filter is refused outright by most of them once ``n`` is far +enough behind the head. Recovery therefore walks the range in chunks of this many blocks. +""" + + +def _provider_errors() -> tuple[type[BaseException], ...]: + """Everything a JSON-RPC provider can throw for one ``eth_getLogs``: web3's own errors, the + ``ValueError`` older/raw providers raise for a JSON-RPC error response, and transport errors.""" + from web3.exceptions import Web3Exception + + errors: list[type[BaseException]] = [Web3Exception, ValueError] + try: + import requests + except ImportError: # pragma: no cover - requests ships with web3's HTTP provider + pass + else: + errors.append(requests.RequestException) + return tuple(errors) + + +def _eth_account(): + try: + from eth_account import Account + except ImportError as exc: # pragma: no cover + raise MissingExtraError("evm", "Ethereum signing") from exc + return Account + + +class Ethereum: + """Transport + optional signer for Ethereum-origin bridge actions. + + Three interchangeable forms:: + + Ethereum(rpc_url, private_key=key) # SDK builds Web3(HTTPProvider(rpc_url)) + Ethereum(w3=my_w3, signer=local_account) # caller's Web3, caller's eth_account signer + Ethereum(w3=my_w3) # signs via w3.eth.default_account + caller middleware, + # else read-only + + Sending: with a ``LocalAccount`` the SDK fills nonce/gas/fee fields, signs, and + ``send_raw_transaction``s; in default-account mode it calls + ``w3.eth.send_transaction`` so the caller's middleware signs. Receipts are + polled on the same ``Web3``. + """ + + def __init__(self, rpc_url: str | None = None, *, w3: Any = None, signer: Any = None, + private_key: str | None = None) -> None: + if (rpc_url is None) == (w3 is None): + raise ConfigurationError("Pass exactly one of rpc_url or w3 to Ethereum(...)") + if signer is not None and private_key is not None: + raise ConfigurationError("Pass at most one of signer or private_key to Ethereum(...)") + if w3 is None: + web3 = _web3() + w3 = web3.Web3(web3.HTTPProvider(rpc_url)) + if private_key is not None: + signer = _eth_account().from_key(private_key) + self._w3 = w3 + self._signer = signer + self._chain_id: int | None = None + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "Ethereum | None": + """``EVM_PRIVATE_KEY`` + ``ETHEREUM_RPC_URL`` (both or neither) → signing connection; neither → None. + + Aliases (the user's live shell / veil config export these names instead): + ``BRIDGE_EVM_PRIVATE_KEY`` for the key, ``BRIDGE_LIVE_ETHEREUM_RPC_URL`` for the RPC url. + The primary variable wins when both a primary and its alias are set; the both-or-neither + rule applies to whichever pair resolves (primary, falling back to alias, per variable). + """ + env = os.environ if env is None else env + key = env.get("EVM_PRIVATE_KEY") or env.get("BRIDGE_EVM_PRIVATE_KEY") + url = env.get("ETHEREUM_RPC_URL") or env.get("BRIDGE_LIVE_ETHEREUM_RPC_URL") + if bool(key) != bool(url): + raise ConfigurationError( + "Set both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL or neither " + "(aliases: BRIDGE_EVM_PRIVATE_KEY, BRIDGE_LIVE_ETHEREUM_RPC_URL)") + if not key: + return None + return cls(url, private_key=key) + + @property + def w3(self) -> Any: + return self._w3 + + @property + def address(self) -> str | None: + """Checksummed sender address: signer → ``w3.eth.default_account`` → ``None``.""" + if self._signer is not None: + return self._signer.address + default = getattr(self._w3.eth, "default_account", None) + if isinstance(default, str) and default: + return _web3().Web3.to_checksum_address(default) + return None + + @property + def can_sign(self) -> bool: + return self.address is not None + + @property + def chain_id(self) -> int: + """``eth_chainId``, read once and cached.""" + if self._chain_id is None: + self._chain_id = int(self._w3.eth.chain_id) + return self._chain_id + + def require_address(self) -> str: + address = self.address + if address is None: + raise ConfigurationError( + "This Ethereum connection is read-only: pass private_key= or signer= to Ethereum(...), " + "or set w3.eth.default_account with signing middleware") + return address + + def send_transaction(self, tx: dict) -> str: + """Broadcast one transaction and return its ``0x`` hash. + + Fills ``from``/``chainId``/``value`` when missing. Local signer: also fills + ``nonce``/``gas``/fee fields, signs, ``send_raw_transaction``. Default-account + mode: ``send_transaction`` (the caller's middleware signs and fills gas). + Read-only: ``ConfigurationError``. + """ + sender = self.require_address() + Web3 = _web3().Web3 + tx = dict(tx) + tx.setdefault("from", sender) + if Web3.to_checksum_address(tx["from"]) != sender: + raise ConfigurationError(f"Transaction sender {tx['from']} does not match the configured account {sender}") + tx.setdefault("chainId", self.chain_id) + tx.setdefault("value", 0) + if self._signer is None: + # Default-account mode: the caller's middleware signs, so the hash only exists once the node + # answers. There is nothing to capture beforehand and nothing to compare the answer against, + # so the ambiguous-send protections below (lost response, echo mismatch, EvmCall's single-use + # guard) cannot apply here — a failed send leaves the caller unable to tell whether the + # transaction is in the mempool. Prefer a local signer (private_key=/signer=) for anything + # that moves funds. + return Web3.to_hex(self._w3.eth.send_transaction(tx)) + tx.setdefault("nonce", self._w3.eth.get_transaction_count(sender, "pending")) + if "gas" not in tx: + estimate_fields = {k: v for k, v in tx.items() if k in ("from", "to", "data", "value")} + tx["gas"] = int(self._w3.eth.estimate_gas(estimate_fields)) * 12 // 10 + if "gasPrice" not in tx and "maxFeePerGas" not in tx: + base_fee = self._w3.eth.get_block("latest").get("baseFeePerGas") + if base_fee is None: + tx["gasPrice"] = int(self._w3.eth.gas_price) + else: + tip = int(self._w3.eth.max_priority_fee) + tx["maxPriorityFeePerGas"] = tip + tx["maxFeePerGas"] = int(base_fee) * 2 + tip + signed = self._signer.sign_transaction(tx) + # The hash is fixed by the signature, so it exists before the broadcast. Capture it first: if the + # RPC answer is lost the node may still have accepted the bytes, and a caller who never learns the + # hash cannot tell a failed send from a landed one (and would resend, risking a double spend). + local_hash = Web3.to_hex(signed.hash) + try: + echoed = Web3.to_hex(self._w3.eth.send_raw_transaction(signed.raw_transaction)) + except Exception as exc: # noqa: BLE001 — any transport/JSON-RPC failure loses the response, not the send + error = BridgeError( + f"Ethereum transaction {local_hash} may have been broadcast; the RPC response was lost: {exc}" + " — check bridge.eth.source_status / the explorer before retrying") + # EvmCall reads this to arm its single-use guard: an ambiguous send must not be retried. + error.broadcast_id = local_hash # type: ignore[attr-defined] + raise error from exc + if echoed.lower() != local_hash.lower(): + error = BridgeError( + f"Ethereum RPC echoed transaction hash {echoed} for a transaction signed as {local_hash}; " + "refusing to checkpoint or follow the wrong hash — check bridge.eth.source_status / the " + f"explorer for {local_hash} before retrying") + # The node ANSWERED, so it took the bytes: they may sit in its mempool under local_hash even + # though it echoed something else. That is the same ambiguity as a lost response, so arm + # EvmCall's single-use guard here too rather than letting a retry sign a second transfer. + error.broadcast_id = local_hash # type: ignore[attr-defined] + raise error + return local_hash + + def wait_for_receipt(self, tx_hash: str, *, timeout_seconds: float, poll_seconds: float) -> dict | None: + """Poll ``wait_for_transaction_receipt``; ``None`` on timeout (a timeout is not a failure).""" + from web3.exceptions import TimeExhausted + + try: + return self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout_seconds, poll_latency=poll_seconds) + except TimeExhausted: + return None + + def get_receipt(self, tx_hash: str) -> dict | None: + """One ``eth_getTransactionReceipt`` read; ``None`` while the transaction is unmined or unknown.""" + from web3.exceptions import TransactionNotFound + + try: + return self._w3.eth.get_transaction_receipt(tx_hash) + except TransactionNotFound: + return None + + +_REGISTRY_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + + +@dataclass(frozen=True) +class _HyperlaneRouteMetadata: + """Validated Hyperlane route metadata (mirrors veil ``protocols/hyperlane/evm.ts`` ``routeMetadata``). + + Every address/domain that reaches a contract call is checked here first, so a corrupted or + malformed registry entry fails with ``ConfigurationError`` before any RPC read. + """ + + router: str + router_type: str # "native" | "collateral" + token: str | None # collateral ERC-20; None on native + mailbox: str + interchain_gas_paymaster: str + interchain_security_module: str + source_chain_id: int + destination_domain: int + destination_router: str + registry_commit: str + requires_approval_reset: bool + + +@dataclass(frozen=True) +class _HyperlaneQuote: + """Router-level facts behind an ``EvmHyperlaneQuote`` (addresses never leave the module).""" + + router: str + router_type: str # "native" | "collateral" + token: str | None # collateral ERC-20 + destination_domain: int + recipient_bytes32: bytes + amount_atomic: int + native_value_atomic: int + native_fee_atomic: int + token_amount_atomic: int # 0 on native routes + allowance_atomic: int | None + requires_approval_reset: bool + + +_DIGIT_STRING_RE = re.compile(r"^[0-9]+$") + + +@dataclass(frozen=True) +class _XReserveRouteMetadata: + """Validated xReserve route metadata (mirrors veil ``protocols/xreserve/evmToAleo.ts`` ``routeMetadata``). + + Every address, domain, program name and fee that reaches a contract call or an Aleo encoder is + checked here first, so a corrupted or malformed registry entry fails with ``ConfigurationError`` + before any RPC read. + """ + + xreserve_contract: str + source_chain_id: int + source_domain: int + remote_domain: int + remote_token_bytes32: bytes + minimum_amount_atomic: int + withdrawal_fee_atomic: int + max_fee_atomic: int + bridge_program: str + wrapper_program: str + remote_token: str + attestation_base_url: str + + +@dataclass(frozen=True) +class _XReserveQuote: + """Contract-level facts behind an ``EvmXReserveQuote``; also rebuilt from receipts during status/recovery.""" + + xreserve_contract: str + token: str + source_chain_id: int + source_domain: int + remote_domain: int + remote_token_bytes32: bytes + remote_recipient_bytes32: bytes + amount_atomic: int + max_fee_atomic: int + hook_data: bytes + balance_atomic: int + allowance_atomic: int + bridge_program: str + wrapper_program: str + + +class EthModule: + """``bridge.eth`` — Ethereum-origin Hyperlane and xReserve actions (reads return values, writes return ``EvmCall``).""" + + def __init__(self, bridge: Any, conn: Ethereum, *, log_scan_chunk_blocks: int = LOG_SCAN_CHUNK_BLOCKS) -> None: + self.bridge = bridge + self.conn = conn + self.registry: Registry = bridge.registry + self.network: str = bridge.network # "mainnet" | "testnet" → aleo. for encoders + self.chain: Chain = self.registry.chain(EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) + self.log_scan_chunk_blocks = log_scan_chunk_blocks # recovery eth_getLogs span; lower it for strict RPCs + + @property + def log_scan_chunk_blocks(self) -> int: + """Blocks per ``eth_getLogs`` request during recovery scans; lower it for strict RPCs. + + Validated on every assignment, not just in the constructor: ``_scan_logs`` advances its + cursor by this many blocks per pass, so a zero or negative chunk would loop forever + against a live chain rather than fail. + """ + return self._log_scan_chunk_blocks + + @log_scan_chunk_blocks.setter + def log_scan_chunk_blocks(self, value: Any) -> None: + if int(value) < 1: + raise ConfigurationError("log_scan_chunk_blocks must be at least 1") + self._log_scan_chunk_blocks = int(value) + + # -- resolution --------------------------------------------------------------------------- + + def _asset(self, ref: Any) -> Asset: + """Accept an ``Asset``, ``"chain/key"``, ``(chain, key)``, or a bare key/symbol on this chain.""" + if isinstance(ref, Asset): + return ref + if isinstance(ref, tuple) or (isinstance(ref, str) and "/" in ref): + return self.registry.asset(ref) + matches = [a for a in self.registry.assets(chain=self.chain.id) + if a.key.lower() == str(ref).lower() or a.symbol.lower() == str(ref).lower()] + if len(matches) != 1: + raise RouteNotFoundError(f"No unique asset {ref!r} on {self.chain.id}; use 'chain/key'") + return matches[0] + + def _hyperlane_route(self, asset: Asset) -> Route: + if asset.chain_id != self.chain.id: + raise RouteNotFoundError(f"{asset.id} is not on {self.chain.id}; bridge.eth drives {self.chain.id} only") + candidates = [r for r in self.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self.bridge.environment) + if r.source_asset_id == asset.id] + if not candidates: + if any(r.source_asset_id == asset.id for r in self.registry.routes(include_unavailable=True, + environment=self.bridge.environment)): + raise BridgeError(f"{asset.id} is not a Hyperlane route source; use deposit_usdc for xReserve") + raise RouteNotFoundError(f"No Hyperlane route from {asset.id}") + active = [r for r in candidates if r.availability == "active"] + if not active: + raise RouteUnavailableError(f"Hyperlane route is not executable ({candidates[0].availability}): {candidates[0].id}") + if len(active) > 1: + raise AmbiguousRouteError(f"{len(active)} active Hyperlane routes from {asset.id}; pass route=") + return active[0] + + def _xreserve_route(self) -> Route: + routes = [r for r in self.registry.routes(protocol="xreserve", environment=self.bridge.environment) + if self.registry.asset(r.source_asset_id).chain_id == self.chain.id] + if len(routes) != 1: + raise RouteNotFoundError(f"Expected exactly one xReserve deposit route from {self.chain.id}, found {len(routes)}") + if routes[0].availability != "active": + raise RouteUnavailableError(f"xReserve route is not executable: {routes[0].id}") + return routes[0] + + def _route_for_plan(self, plan: Plan) -> Route: + """Re-resolve the route from the live registry (invariant 1); never trust plan-carried addresses.""" + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") + route = self.registry.route(plan.route_id) + if route.source_asset_id != plan.source_asset_id or route.destination_asset_id != plan.destination_asset_id: + raise BridgeError(f"Plan assets do not match configured route {route.id}") + if route.availability != "active": + raise RouteUnavailableError(f"Route is not executable: {route.id}") + return route + + def _plan_route(self, plan: Plan, protocol: str) -> Route: + """Re-resolve a caller-supplied ``Plan``'s route by id (never trust plan-carried addresses).""" + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") + try: + route = self.registry.route(plan.route_id) + except RouteNotFoundError as exc: + raise RouteUnavailableError(f"Plan route {plan.route_id} is not in registry {self.registry.version}") from exc + if route.protocol != protocol: + raise RouteUnavailableError(f"{route.id} is a {route.protocol} route, not a {protocol} one") + if route.availability != "active": + raise RouteUnavailableError(f"Route is not executable ({route.availability}): {route.id}") + return route + + def _plan_sender(self, plan: Plan, *, require_signer: bool) -> str: + """``plan.sender`` as a checksummed EVM address, bound to the connected account when there is one.""" + Web3 = _web3().Web3 + sender = plan.sender + if not isinstance(sender, str) or not Web3.is_address(sender) or Web3.to_checksum_address(sender) != sender: + raise BridgeError(f"Plan sender must be a checksummed EVM address; got {sender!r}") + connected = self.conn.require_address() if require_signer else self.conn.address + if connected is not None and sender != connected: # the rule EvmCall.send() applies + raise ConfigurationError(f"Prepared sender {sender} does not match connected account {connected}") + return sender + + def _assert_plan_matches(self, plan: Plan, route: Route, *, sender: str, recipient: str, amount_atomic: int, + mint_mode: str) -> None: + """The plan must be exactly what this module would have prepared for the same transfer.""" + rebuilt = _plan_for(self.registry, route, amount_atomic=amount_atomic, recipient=recipient, sender=sender, + mint_mode=mint_mode) + for field in fields(Plan): + mine, theirs = getattr(rebuilt, field.name), getattr(plan, field.name) + if mine != theirs: + raise BridgeError(f"plan does not match the requested transfer: {field.name} is {theirs!r} " + f"but this transfer prepares {mine!r}") + + def _from_plan(self, plan: Plan, protocol: str, *, recipient: str | None, amount: Any, amount_atomic: int | None, + mint_mode: str | None, require_signer: bool) -> tuple[Route, str, str, int, str]: + """Validate ``plan=`` and return ``(route, sender, recipient, amount_atomic, mint_mode)``. + + Explicit ``recipient``/``amount``/``mint_mode`` arguments override the plan's own values and are + then caught by the field-by-field equality check, so a plan can never silently disagree with + the call that carries it. + """ + route = self._plan_route(plan, protocol) + sender = self._plan_sender(plan, require_signer=require_signer) + recipient = plan.recipient if recipient is None else recipient + mint_mode = plan.mint_mode if mint_mode is None else mint_mode + if amount is None and amount_atomic is None: + amount_atomic = plan.amount_atomic + atomic = self._amount_atomic(route, amount, amount_atomic) + self._assert_plan_matches(plan, route, sender=sender, recipient=recipient, amount_atomic=atomic, + mint_mode=mint_mode) + return route, sender, recipient, atomic, mint_mode + + def assert_chain(self, route: Route) -> None: + expected = int(route.metadata["sourceChainId"]) + actual = self.conn.chain_id + if actual != expected: + raise ChainMismatchError(f"EVM connection is on chain {actual}; expected {expected} for {route.id}") + + def _recipient_bytes32(self, route: Route, recipient: str) -> bytes: + destination = self.registry.asset(route.destination_asset_id) + if destination.address_regex and not re.fullmatch(destination.address_regex, recipient): + raise InvalidRecipientError(f"Recipient does not match the {destination.chain_id} address format: {recipient}") + return encoding.aleo_address_to_bytes32(recipient) + + def _amount_atomic(self, route: Route, amount: Any, amount_atomic: int | None) -> int: + source = self.registry.asset(route.source_asset_id) + destination = self.registry.asset(route.destination_asset_id) + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + if atomic <= 0: + raise InvalidAmountError("Amount must be positive") + parse_decimal_amount(format_decimal_amount(atomic, source.decimals), destination.decimals) # precision on both sides + return atomic + + def _owner(self, sender: str | None) -> str | None: + if sender is None: + return self.conn.address + return _web3().Web3.to_checksum_address(sender) + + # -- contracts ---------------------------------------------------------------------------- + + def _contract(self, address: str, abi: list) -> Any: + return self.conn.w3.eth.contract(address=_web3().Web3.to_checksum_address(address), abi=abi) + + def _erc20(self, address: str) -> Any: + return self._contract(address, ERC20_ABI) + + def _native_fee(self, amount_wei: int) -> Fee: + native = [a for a in self.registry.assets(chain=self.chain.id) if a.kind == "native"] + asset_id = native[0].id if native else f"{self.chain.id}/{self.chain.native_symbol.lower()}" + decimals = native[0].decimals if native else 18 # only an unlisted chain falls back to the EVM default + return Fee(kind="network", chain_id=self.chain.id, asset_id=asset_id, + amount=format_decimal_amount(amount_wei, decimals), estimated=True) + + # -- Hyperlane quote ---------------------------------------------------------------------- + + def _metadata_address(self, meta: Mapping[str, Any], key: str, route_id: str) -> str: + """Checksum a metadata address field; any malformed value is a ``ConfigurationError``, never + a raw ``ValueError``/``KeyError`` (a missing key reads as ``None`` via ``.get``, which also fails here).""" + value = meta.get(key) + try: + return _web3().Web3.to_checksum_address(str(value)) + except (ValueError, TypeError) as exc: + raise ConfigurationError(f"Hyperlane route metadata {key!r} is not a valid address ({route_id}): {value!r}") from exc + + def _hyperlane_metadata(self, route: Route) -> _HyperlaneRouteMetadata: + """Brief §3.1 route metadata validator (mirrors veil ``protocols/hyperlane/evm.ts`` ``routeMetadata``). + + Called by every path that is about to touch a Hyperlane contract (``_quote_hyperlane``, and + transitively ``transfer_remote``'s step builder through the ``_HyperlaneQuote`` it returns) so + no unvalidated address or domain from the registry ever reaches an RPC call. + """ + if route is None or route.protocol != "hyperlane" or route.availability != "active": + raise RouteUnavailableError(f"Hyperlane route is not executable: {getattr(route, 'id', route)!r}") + meta = route.metadata + router = self._metadata_address(meta, "routerAddress", route.id) + mailbox = self._metadata_address(meta, "mailboxAddress", route.id) + igp = self._metadata_address(meta, "interchainGasPaymaster", route.id) + ism = self._metadata_address(meta, "interchainSecurityModule", route.id) + source_chain_id = meta.get("sourceChainId") + if isinstance(source_chain_id, bool) or not isinstance(source_chain_id, int) or source_chain_id <= 0: + raise ConfigurationError( + f"Hyperlane route metadata sourceChainId must be a positive int ({route.id}): {source_chain_id!r}") + destination_domain = meta.get("destinationDomain") + if isinstance(destination_domain, bool) or not isinstance(destination_domain, int) \ + or not (0 <= destination_domain <= 2**32 - 1): + raise ConfigurationError( + f"Hyperlane route metadata destinationDomain must be a uint32 ({route.id}): {destination_domain!r}") + router_type = meta.get("routerType") + if router_type not in ("native", "collateral"): + raise ConfigurationError( + f"Hyperlane route metadata routerType must be native or collateral ({route.id}): {router_type!r}") + token = self._metadata_address(meta, "tokenAddress", route.id) if router_type == "collateral" else None + destination_router = meta.get("destinationRouter") + if not isinstance(destination_router, str) or not destination_router.strip(): + raise ConfigurationError(f"Hyperlane route metadata destinationRouter must be non-empty ({route.id})") + registry_commit = meta.get("registryCommit") + if not isinstance(registry_commit, str) or not _REGISTRY_COMMIT_RE.fullmatch(registry_commit): + raise ConfigurationError( + f"Hyperlane route metadata registryCommit must be 40 hex chars ({route.id}): {registry_commit!r}") + return _HyperlaneRouteMetadata( + router=router, router_type=router_type, token=token, mailbox=mailbox, + interchain_gas_paymaster=igp, interchain_security_module=ism, source_chain_id=source_chain_id, + destination_domain=destination_domain, destination_router=destination_router, + registry_commit=registry_commit, requires_approval_reset=meta.get("requiresApprovalReset") is True) + + def _quote_hyperlane(self, route: Route, recipient_bytes32: bytes, amount_atomic: int, owner: str | None) -> _HyperlaneQuote: + """Brief §3.1: chain assert → metadata validation → quoteTransferRemote → native/collateral split → allowance.""" + self.assert_chain(route) + meta = self._hyperlane_metadata(route) + Web3 = _web3().Web3 + quotes = self._contract(meta.router, WARP_ROUTE_ABI).functions.quoteTransferRemote( + meta.destination_domain, recipient_bytes32, amount_atomic).call() + native_value = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == ZERO_ADDRESS) + if meta.router_type == "native": + if native_value < amount_atomic: + raise BridgeError("Native Hyperlane quote does not cover the transfer amount") + return _HyperlaneQuote(meta.router, "native", None, meta.destination_domain, recipient_bytes32, + amount_atomic, native_value, native_value - amount_atomic, 0, None, False) + token_amount = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == meta.token) + if token_amount < amount_atomic: + raise BridgeError("Collateral Hyperlane quote does not cover the transfer amount") + allowance = int(self._erc20(meta.token).functions.allowance(owner, meta.router).call()) if owner else None + return _HyperlaneQuote(meta.router, "collateral", meta.token, meta.destination_domain, recipient_bytes32, + amount_atomic, native_value, native_value, token_amount, allowance, + meta.requires_approval_reset) + + def quote_transfer_remote(self, asset: Any = None, recipient: str | None = None, *, amount: Any = None, + amount_atomic: int | None = None, route: Route | None = None, sender: str | None = None, + plan: Plan | None = None) -> EvmHyperlaneQuote: + """Quote an Ethereum → Aleo Hyperlane transfer without signing. + + Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so + ``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): + ``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 + allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + + ``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and + amount, and is validated against the live registry. It is mutually exclusive with + ``asset=``/``route=``/``sender=``. + """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if plan is not None: + if asset is not None or route is not None or sender is not None: + raise ValueError("Pass plan= or asset=/route=/sender=, not both") + route, owner, recipient, atomic, _ = self._from_plan( + plan, "hyperlane", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=None, require_signer=False) + recipient32 = self._recipient_bytes32(route, recipient) + else: + if asset is None and route is None: + raise ValueError("quote_transfer_remote needs asset=, route= or plan=") + route = route or self._hyperlane_route(self._asset(asset)) + if route.protocol != "hyperlane": + raise BridgeError(f"{route.id} is not a Hyperlane route; use quote_deposit_usdc for xReserve") + atomic = self._amount_atomic(route, amount, amount_atomic) + recipient32 = self._recipient_bytes32(route, recipient) + owner = self._owner(sender) + q = self._quote_hyperlane(route, recipient32, atomic, owner) + destination = self.registry.asset(route.destination_asset_id) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner) + approval_required = None if q.allowance_atomic is None else q.allowance_atomic < q.token_amount_atomic + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=plan, fees=(self._native_fee(q.native_fee_atomic),), + amount_out=format_decimal_amount(atomic, destination.decimals), + recipient_bytes32=recipient32, native_value_atomic=q.native_value_atomic, + native_fee_atomic=q.native_fee_atomic, approval_required=approval_required) + + # -- xReserve quote ------------------------------------------------------------------------- + + def _metadata_digits(self, meta: Mapping[str, Any], key: str, route_id: str) -> int: + """A digit-string atomic-amount metadata field as ``int``; never bool/float/junk.""" + value = meta.get(key) + if not isinstance(value, str) or not _DIGIT_STRING_RE.fullmatch(value): + raise ConfigurationError(f"xReserve route metadata {key!r} must be a digit string ({route_id}): {value!r}") + return int(value) + + def _metadata_aleo_program(self, meta: Mapping[str, Any], key: str, route_id: str) -> str: + value = meta.get(key) + if not isinstance(value, str) or not value.endswith(".aleo") or value == ".aleo": + raise ConfigurationError(f"xReserve route metadata {key!r} must be an .aleo program name ({route_id}): {value!r}") + return value + + def _xreserve_metadata(self, route: Route) -> _XReserveRouteMetadata: + """Brief §3.2 route metadata validator (mirrors veil ``protocols/xreserve/evmToAleo.ts`` ``routeMetadata``). + + Called by every path that is about to touch the xReserve contract or an Aleo program address + (``_quote_xreserve``, ``_xreserve_recipient_bytes32``, and Task 6's deposit execute) so no + unvalidated address, domain, fee, or program name from the registry ever reaches an RPC call. + """ + if route is None or route.protocol != "xreserve" or route.availability != "active": + raise RouteUnavailableError(f"xReserve route is not executable: {getattr(route, 'id', route)!r}") + source_chain = self.registry.chain(self.registry.asset(route.source_asset_id).chain_id) + if source_chain.family != "evm": + raise ConfigurationError(f"xReserve route source chain must be an EVM chain ({route.id}): {source_chain.id!r}") + expected_aleo_chain = "aleo-testnet" if self.network == "testnet" else "aleo" + destination_chain_id = self.registry.asset(route.destination_asset_id).chain_id + if destination_chain_id != expected_aleo_chain: + raise ConfigurationError( + f"xReserve route destination chain must be {expected_aleo_chain!r} ({route.id}): {destination_chain_id!r}") + meta = route.metadata + xreserve_contract = self._metadata_address(meta, "xReserveContract", route.id) + source_chain_id = meta.get("sourceChainId") + if isinstance(source_chain_id, bool) or not isinstance(source_chain_id, int) or source_chain_id <= 0: + raise ConfigurationError( + f"xReserve route metadata sourceChainId must be a positive int ({route.id}): {source_chain_id!r}") + source_domain = meta.get("sourceDomain") + if isinstance(source_domain, bool) or not isinstance(source_domain, int) or source_domain < 0: + raise ConfigurationError( + f"xReserve route metadata sourceDomain must be a non-negative int ({route.id}): {source_domain!r}") + remote_domain = meta.get("remoteDomain") + if isinstance(remote_domain, bool) or not isinstance(remote_domain, int) or remote_domain < 0: + raise ConfigurationError( + f"xReserve route metadata remoteDomain must be a non-negative int ({route.id}): {remote_domain!r}") + remote_token_bytes32_raw = meta.get("remoteTokenBytes32") + if not isinstance(remote_token_bytes32_raw, str): + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 must be a hex string ({route.id}): {remote_token_bytes32_raw!r}") + hex_text = remote_token_bytes32_raw[2:] if remote_token_bytes32_raw[:2] in ("0x", "0X") else remote_token_bytes32_raw + try: + remote_token_bytes32 = bytes.fromhex(hex_text) + except ValueError as exc: + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 is not valid hex ({route.id}): {remote_token_bytes32_raw!r}") from exc + if len(remote_token_bytes32) != 32: + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 must be exactly 32 bytes ({route.id}): {remote_token_bytes32_raw!r}") + minimum_amount_atomic = self._metadata_digits(meta, "minimumAmountAtomic", route.id) + withdrawal_fee_atomic = self._metadata_digits(meta, "withdrawalFeeAtomic", route.id) + max_fee_atomic = self._metadata_digits(meta, "maxFeeAtomic", route.id) + bridge_program = self._metadata_aleo_program(meta, "bridgeProgram", route.id) + wrapper_program = self._metadata_aleo_program(meta, "wrapperProgram", route.id) + remote_token = self._metadata_aleo_program(meta, "remoteToken", route.id) + attestation_base_url = meta.get("attestationBaseUrl") + if not isinstance(attestation_base_url, str) or not attestation_base_url.startswith("https://"): + raise ConfigurationError( + f"xReserve route metadata attestationBaseUrl must start with https:// ({route.id}): {attestation_base_url!r}") + return _XReserveRouteMetadata( + xreserve_contract=xreserve_contract, source_chain_id=source_chain_id, source_domain=source_domain, + remote_domain=remote_domain, remote_token_bytes32=remote_token_bytes32, + minimum_amount_atomic=minimum_amount_atomic, withdrawal_fee_atomic=withdrawal_fee_atomic, + max_fee_atomic=max_fee_atomic, bridge_program=bridge_program, wrapper_program=wrapper_program, + remote_token=remote_token, attestation_base_url=attestation_base_url) + + def _xreserve_recipient_bytes32(self, route: Route, meta: _XReserveRouteMetadata, recipient: str, + mint_mode: str) -> bytes: + """Invariant 7: private deposits are addressed to the wrapper program's account address.""" + self._recipient_bytes32(route, recipient) # validates the intended recipient + if mint_mode == "private": + return encoding.aleo_address_to_bytes32(encoding.aleo_program_address(meta.wrapper_program, self.network)) + return encoding.aleo_address_to_bytes32(recipient) + + def _quote_xreserve(self, route: Route, recipient: str, amount_atomic: int, owner: str | None, + mint_mode: str, secret_nonce: str) -> _XReserveQuote: + """Brief §3.2 quote: chain assert → metadata validation → minimum → hook data → wire recipient → + balanceOf/allowance.""" + if mint_mode not in ("public", "record", "private"): + raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") + self.assert_chain(route) + meta = self._xreserve_metadata(route) + if amount_atomic < meta.minimum_amount_atomic: + raise InvalidAmountError(f"xReserve minimum deposit is {meta.minimum_amount_atomic} atomic units") + if owner is None: + raise ConfigurationError("xReserve quotes read the depositor's balance: pass sender= or configure a signer") + source = self.registry.asset(route.source_asset_id) + if source.locator is None or source.locator.kind != "evm-contract": + raise RouteUnavailableError(f"xReserve source token contract is missing: {route.id}") + token = _web3().Web3.to_checksum_address(source.locator.value) + hook_data = encoding.xreserve_hook_data(mint_mode, recipient, self.network, secret_nonce) + remote_recipient = self._xreserve_recipient_bytes32(route, meta, recipient, mint_mode) + erc20 = self._erc20(token) + balance = int(erc20.functions.balanceOf(owner).call()) + allowance = int(erc20.functions.allowance(owner, meta.xreserve_contract).call()) + if balance < amount_atomic: + raise InsufficientBalanceError(f"Insufficient {source.symbol} balance: {balance} < {amount_atomic} atomic units") + return _XReserveQuote( + xreserve_contract=meta.xreserve_contract, token=token, source_chain_id=meta.source_chain_id, + source_domain=meta.source_domain, remote_domain=meta.remote_domain, + remote_token_bytes32=meta.remote_token_bytes32, + remote_recipient_bytes32=remote_recipient, amount_atomic=amount_atomic, + max_fee_atomic=meta.max_fee_atomic, hook_data=hook_data, + balance_atomic=balance, allowance_atomic=allowance, + bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) + + def quote_deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str | None = None, secret_nonce: str = "0scalar", + sender: str | None = None, route: Route | None = None, + plan: Plan | None = None) -> EvmXReserveQuote: + """Quote a USDC → USDCx xReserve deposit without signing. + + Checks the 2 USDC minimum, derives the 65-byte hook (``public``/``record``/``private``; + private commits ``recipient`` with ``secret_nonce`` via BHP256) and the wire recipient + (the shielded wrapper program's address for ``private``), and reads the depositor's + USDC balance and xReserve allowance. ``secret_nonce`` is never stored by the SDK. + ``mint_mode`` defaults to ``plan.mint_mode`` when a plan is given, else ``"public"``. + + ``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient, amount + and mint mode, and is validated against the live registry. It is mutually exclusive with + ``route=``/``sender=``. + """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if plan is not None: + if route is not None or sender is not None: + raise ValueError("Pass plan= or route=/sender=, not both") + route, owner, recipient, atomic, mint_mode = self._from_plan( + plan, "xreserve", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=mint_mode, require_signer=False) + else: + route = route or self._xreserve_route() + mint_mode = "public" if mint_mode is None else mint_mode + atomic = self._amount_atomic(route, amount, amount_atomic) + owner = self._owner(sender) + q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) + destination = self.registry.asset(route.destination_asset_id) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner, mint_mode=mint_mode) + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), + amount_out=format_decimal_amount(atomic, destination.decimals), + hook_data=q.hook_data, remote_recipient_bytes32=q.remote_recipient_bytes32, + balance_atomic=q.balance_atomic, allowance_atomic=q.allowance_atomic, + approval_required=q.allowance_atomic < atomic, max_fee_atomic=q.max_fee_atomic) + + # -- Hyperlane execute -------------------------------------------------------------------- + + def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: + """Hyperlane Mailbox ``DispatchId(bytes32 indexed messageId)`` from a confirmed receipt; ``None`` if absent.""" + from web3.logs import DISCARD + + Web3 = _web3().Web3 + address = self._hyperlane_metadata(route).mailbox + mailbox = self._contract(address, MAILBOX_ABI) + # process_receipt decodes by topic alone: another contract's DispatchId(bytes32) would + # otherwise be read as this transfer's message id, so filter on the emitting address first. + events = [ev for ev in mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) + if Web3.to_checksum_address(ev["address"]) == address] + if not events: + return None + return Web3.to_hex(events[0]["args"]["messageId"]) # veil messageIdFromReceipt: first match wins + + @staticmethod + def _hyperlane_protocol_state(route: Route, *, recipient_bytes32: bytes, destination_domain: int, + native_value_atomic: int, amount_atomic: int, approval_tx_ids: list[str], + sender: str | None, message_id: str | None = None) -> dict[str, Any]: + state: dict[str, Any] = { + "routeId": route.id, "approvalTxIds": list(approval_tx_ids), "sourceSender": sender, + "recipientBytes32": "0x" + recipient_bytes32.hex(), "destinationDomain": destination_domain, + "nativeValueAtomic": str(native_value_atomic), "amountAtomic": str(amount_atomic), + } + if message_id is not None: + state["messageId"] = message_id + return state + + def _hyperlane_result(self, route: Route, q: "_HyperlaneQuote", outcome: EvmOutcome) -> DispatchReceipt: + approvals = list(outcome.approval_tx_ids) + if outcome.status == "CONFIRMED": + message_id = self._message_id_from_receipt(route, outcome.receipt) + status, rid = Status.DELIVERY_PENDING, message_id or outcome.source_tx_id + else: + message_id, status = None, Status(outcome.status) + rid = outcome.source_tx_id or approvals[-1] + state = self._hyperlane_protocol_state( + route, recipient_bytes32=q.recipient_bytes32, destination_domain=q.destination_domain, + native_value_atomic=q.native_value_atomic, amount_atomic=q.amount_atomic, + approval_tx_ids=approvals, sender=outcome.sender, message_id=message_id) + receipt = Receipt(id=rid, protocol="hyperlane", status=status, source_tx_id=outcome.source_tx_id, protocol_state=state) + return DispatchReceipt(transaction_id=outcome.source_tx_id or approvals[-1], route_id=route.id, + message_id=message_id, amount_atomic=q.amount_atomic, receipt=receipt) + + def transfer_remote(self, asset: Any = None, recipient: str | None = None, *, amount: Any = None, + amount_atomic: int | None = None, plan: Plan | None = None) -> EvmCall[DispatchReceipt]: + """Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + + Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the + quoted token amount only when the allowance is short (USDT: a non-zero allowance is + reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes + send the fee only. Each hash is checkpointed before polling; a timeout returns a + pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + + ``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is + re-resolved by id against the live registry, the sender must be the connected account, and + the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. + """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if plan is not None: + if asset is not None: + raise ValueError("Pass plan= or asset=, not both") + route, sender, recipient, atomic, _ = self._from_plan( + plan, "hyperlane", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=None, require_signer=True) + else: + if asset is None: + raise ValueError("transfer_remote needs asset= or plan=") + route = self._hyperlane_route(self._asset(asset)) + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender) + recipient32 = self._recipient_bytes32(route, recipient) + latest: dict[str, _HyperlaneQuote] = {} + + def steps(owner: str) -> list[EvmStep]: + q = self._quote_hyperlane(route, recipient32, atomic, owner) # last responsible moment + latest["q"] = q + out: list[EvmStep] = [] + if q.router_type == "collateral" and (q.allowance_atomic or 0) < q.token_amount_atomic: + token = self._erc20(q.token) + if (q.allowance_atomic or 0) > 0 and q.requires_approval_reset: + out.append(EvmStep("approve", q.token, token.encode_abi("approve", args=[q.router, 0]))) + out.append(EvmStep("approve", q.token, token.encode_abi("approve", args=[q.router, q.token_amount_atomic]))) + warp = self._contract(q.router, WARP_ROUTE_ABI) + out.append(EvmStep("main", q.router, + warp.encode_abi("transferRemote", args=[q.destination_domain, recipient32, atomic]), + q.native_value_atomic)) + return out + + def finish(outcome: EvmOutcome) -> DispatchReceipt: + return self._hyperlane_result(route, latest["q"], outcome) + + return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, + store=self.bridge.checkpoints) + + # -- xReserve execute ----------------------------------------------------------------------- + + @staticmethod + def _xreserve_protocol_state(route: Route, q: _XReserveQuote, *, approval_tx_ids: list[str], sender: str | None, + mint_mode: str, intended_recipient: str) -> dict[str, Any]: + return { + "routeId": route.id, "approvalTxIds": list(approval_tx_ids), "sourceSender": sender, + "mintMode": mint_mode, "intendedRecipient": intended_recipient, + "xReserveContract": q.xreserve_contract, "tokenAddress": q.token, "sourceChainId": q.source_chain_id, + "remoteDomain": q.remote_domain, "remoteRecipientBytes32": "0x" + q.remote_recipient_bytes32.hex(), + "hookData": "0x" + q.hook_data.hex(), "amountAtomic": str(q.amount_atomic), "maxFeeAtomic": str(q.max_fee_atomic), + } + + def _confirmed_deposit_receipt(self, route: Route, q: _XReserveQuote, *, owner: str, approval_tx_ids: list[str], + source_tx_id: str, receipt: Any, mint_mode: str, intended_recipient: str) -> Receipt: + """Brief §3.2 confirm: find the xReserve ``DepositedToRemote`` log, re-verify every field, derive nonce/payload/hash.""" + from web3.logs import DISCARD + + Web3 = _web3().Web3 + # Kept even though send() already asserted success: _recover_xreserve_from_history reaches + # this with receipts nothing has checked, so the revert test must live here too. + if int(receipt["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {source_tx_id}") + xreserve = self._contract(q.xreserve_contract, XRESERVE_ABI) + events = [ev for ev in xreserve.events.DepositedToRemote().process_receipt(receipt, errors=DISCARD) + if Web3.to_checksum_address(ev["address"]) == q.xreserve_contract] + if not events: + raise BridgeError("Confirmed receipt does not contain a valid DepositedToRemote event") + + def matches(args: Mapping[str, Any]) -> bool: + return (Web3.to_checksum_address(args["localToken"]) == q.token + and Web3.to_checksum_address(args["localDepositor"]) == owner + and int(args["value"]) == q.amount_atomic + and int(args["remoteDomain"]) == q.remote_domain + and bytes(args["remoteRecipient"]) == q.remote_recipient_bytes32 + and bytes(args["remoteToken"]) == q.remote_token_bytes32 + and int(args["maxFee"]) == q.max_fee_atomic + and bytes(args["hookData"]) == q.hook_data) + + # One transaction can batch several accounts' deposits, so take OUR event rather than the + # last one: every one of the eight canonical fields has to match for it to be ours. + ev = next((e for e in events if matches(e["args"])), None) + if ev is None: + raise BridgeError("DepositedToRemote event does not match the prepared transfer") + a = ev["args"] + log_index = int(ev["logIndex"]) + if log_index < 0: + raise BridgeError("DepositedToRemote log index is missing or invalid") + # xReserve identifies a deposit by (source domain, tx hash, log index); the ordered payload is + # what Circle signs, so its keccak is the only safe attestation lookup key. + nonce = encoding.xreserve_deposit_nonce(q.source_domain, bytes.fromhex(source_tx_id[2:]), log_index) + payload = encoding.xreserve_deposit_payload( + amount=int(a["value"]), remote_domain=int(a["remoteDomain"]), remote_token=bytes(a["remoteToken"]), + remote_recipient=bytes(a["remoteRecipient"]), local_token=Web3.to_checksum_address(a["localToken"]), + depositor=Web3.to_checksum_address(a["localDepositor"]), max_fee=int(a["maxFee"]), nonce=nonce, + hook_data=bytes(a["hookData"])) + message_hash = "0x" + encoding.xreserve_message_hash(payload).hex() + state = self._xreserve_protocol_state(route, q, approval_tx_ids=approval_tx_ids, sender=owner, + mint_mode=mint_mode, intended_recipient=intended_recipient) + state.update({"sourceDomain": q.source_domain, "remoteDomain": q.remote_domain, "depositLogIndex": log_index, + "nonce": "0x" + nonce.hex(), "payload": "0x" + payload.hex(), "messageHash": message_hash, + "bridgeProgram": q.bridge_program, "wrapperProgram": q.wrapper_program}) + return Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, + source_tx_id=source_tx_id, protocol_state=state) + + def _xreserve_result(self, route: Route, q: _XReserveQuote, outcome: EvmOutcome, *, mint_mode: str, + intended_recipient: str) -> DepositReceipt: + approvals = list(outcome.approval_tx_ids) + if outcome.status == "CONFIRMED": + receipt = self._confirmed_deposit_receipt(route, q, owner=outcome.sender, approval_tx_ids=approvals, + source_tx_id=outcome.source_tx_id, receipt=outcome.receipt, + mint_mode=mint_mode, intended_recipient=intended_recipient) + return DepositReceipt(transaction_id=outcome.source_tx_id, route_id=route.id, message_hash=receipt.id, + nonce=receipt.protocol_state["nonce"], receipt=receipt) + rid = outcome.source_tx_id or approvals[-1] + receipt = Receipt(id=rid, protocol="xreserve", status=Status(outcome.status), source_tx_id=outcome.source_tx_id, + protocol_state=self._xreserve_protocol_state(route, q, approval_tx_ids=approvals, sender=outcome.sender, + mint_mode=mint_mode, intended_recipient=intended_recipient)) + return DepositReceipt(transaction_id=rid, route_id=route.id, message_hash="", nonce="", receipt=receipt) + + def deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str | None = None, secret_nonce: str = "0scalar", + plan: Plan | None = None) -> EvmCall[DepositReceipt]: + """Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + + ``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private + record), or ``private`` (deposit addressed to the shielded wrapper program; you must later + run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, + which the SDK never stores). Approves exactly the amount only when the allowance is + short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` + carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to + ``plan.mint_mode`` when a plan is given, else ``"public"``. + + ``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is + re-resolved by id against the live registry, the sender must be the connected account, and + the plan must equal what this call would have prepared itself. ``secret_nonce`` is never + part of a plan, so a private deposit must still pass the same one it was quoted with. + """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if plan is not None: + route, sender, recipient, atomic, mint_mode = self._from_plan( + plan, "xreserve", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=mint_mode, require_signer=True) + else: + route = self._xreserve_route() + mint_mode = "public" if mint_mode is None else mint_mode + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender, mint_mode=mint_mode) + latest: dict[str, _XReserveQuote] = {} + + def steps(owner: str) -> list[EvmStep]: + q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) # fresh balance/allowance + latest["q"] = q + out: list[EvmStep] = [] + if q.allowance_atomic < atomic: + out.append(EvmStep("approve", q.token, self._erc20(q.token).encode_abi("approve", args=[q.xreserve_contract, atomic]))) + xreserve = self._contract(q.xreserve_contract, XRESERVE_ABI) + out.append(EvmStep("main", q.xreserve_contract, xreserve.encode_abi( + "depositToRemote", args=[atomic, q.remote_domain, q.remote_recipient_bytes32, q.token, q.max_fee_atomic, q.hook_data]), 0)) + return out + + def finish(outcome: EvmOutcome) -> DepositReceipt: + return self._xreserve_result(route, latest["q"], outcome, mint_mode=mint_mode, intended_recipient=recipient) + + return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, store=self.bridge.checkpoints) + + # -- status --------------------------------------------------------------------------------- + + @staticmethod + def _require_hash(value: Any, what: str) -> str: + if not isinstance(value, str) or not _HASH_RE.fullmatch(value): + raise CheckpointInvalidError(f"Receipt is missing a valid {what}") + return value + + @staticmethod + def _failed(receipt: Receipt, key: str, message: str) -> Receipt: + return receipt.replace(status=Status.FAILED, next_action=None, + protocol_state={**receipt.protocol_state, key: message}) + + def _validate_hyperlane_state(self, route: Route, plan: Plan, receipt: Receipt) -> bytes: + """Bind every value that affects the dispatch before trusting checkpointed transaction ids.""" + state = receipt.protocol_state + recipient32 = encoding.aleo_address_to_bytes32(plan.recipient) + if (receipt.protocol != "hyperlane" + or state.get("destinationDomain") != self._hyperlane_metadata(route).destination_domain + or state.get("amountAtomic") != str(plan.amount_atomic) + or not isinstance(state.get("recipientBytes32"), str) + or state["recipientBytes32"].lower() != "0x" + recipient32.hex()): + raise CheckpointInvalidError("Hyperlane checkpoint does not match the prepared transfer") + ids = state.get("approvalTxIds", []) + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.fullmatch(i) for i in ids): + raise CheckpointInvalidError("Hyperlane checkpoint contains invalid approval transaction ids") + return recipient32 + + def _hyperlane_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> Receipt: + self._validate_hyperlane_state(route, plan, receipt) + self.assert_chain(route) + source_tx_id = self._require_hash(receipt.source_tx_id, "source transaction id") + observed = self.conn.get_receipt(source_tx_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM transaction reverted: {source_tx_id}") + message_id = self._message_id_from_receipt(route, observed) + state = dict(receipt.protocol_state) + if message_id is not None: + state["messageId"] = message_id + return receipt.replace(id=message_id or source_tx_id, status=Status.DELIVERY_PENDING, protocol_state=state) + + def _xreserve_quote_from_state(self, route: Route, plan: Plan, receipt: Receipt) -> _XReserveQuote: + """veil ``resumeQuote``: rebuild the deposit arguments from saved state and bind them to the plan.""" + Web3 = _web3().Web3 + s = receipt.protocol_state + if receipt.protocol != "xreserve": + raise CheckpointInvalidError("Checkpoint does not match the prepared xReserve route") + if s.get("mintMode") != plan.mint_mode or s.get("intendedRecipient") != plan.recipient: + raise CheckpointInvalidError("Checkpoint does not match the prepared xReserve recipient") + try: + ok = (Web3.is_address(s["xReserveContract"]) and Web3.is_address(s["tokenAddress"]) + and isinstance(s["sourceChainId"], int) and isinstance(s["remoteDomain"], int) + and _HASH_RE.fullmatch(s["remoteRecipientBytes32"]) is not None + and isinstance(s["hookData"], str) and len(s["hookData"]) == 132 and s["hookData"].startswith("0x") + and str(s["amountAtomic"]).isdigit() and str(s["maxFeeAtomic"]).isdigit()) + except (KeyError, TypeError): + ok = False + if not ok: + raise CheckpointInvalidError("Checkpoint contains invalid xReserve submission state") + ids = s.get("approvalTxIds", []) + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.fullmatch(i) for i in ids): + raise CheckpointInvalidError("Checkpoint contains invalid xReserve approval transaction ids") + meta = self._xreserve_metadata(route) # reuse the registry validator rather than trusting raw metadata again + return _XReserveQuote( + xreserve_contract=Web3.to_checksum_address(s["xReserveContract"]), token=Web3.to_checksum_address(s["tokenAddress"]), + source_chain_id=int(s["sourceChainId"]), source_domain=meta.source_domain, remote_domain=int(s["remoteDomain"]), + remote_token_bytes32=meta.remote_token_bytes32, + remote_recipient_bytes32=bytes.fromhex(s["remoteRecipientBytes32"][2:]), amount_atomic=int(s["amountAtomic"]), + max_fee_atomic=int(s["maxFeeAtomic"]), hook_data=bytes.fromhex(s["hookData"][2:]), balance_atomic=0, allowance_atomic=0, + bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) + + def _observed_owner(self, plan: Plan, receipt: Receipt | None) -> str: + """Prefer the sender committed to the receipt or plan so read-only recovery never needs a signer.""" + Web3 = _web3().Web3 + saved = receipt.protocol_state.get("sourceSender") if receipt is not None else None + for candidate in (saved, plan.sender, self.conn.address): + if isinstance(candidate, str) and Web3.is_address(candidate): + return Web3.to_checksum_address(candidate) + raise ConfigurationError("Read-only EVM access requires the prepared sender address (plan.sender or protocol_state.sourceSender)") + + def _xreserve_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> Receipt: + q = self._xreserve_quote_from_state(route, plan, receipt) + self.assert_chain(route) + owner = self._observed_owner(plan, receipt) + source_tx_id = self._require_hash(receipt.source_tx_id, "xReserve source transaction id") + observed = self.conn.get_receipt(source_tx_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM transaction reverted: {source_tx_id}") + return self._confirmed_deposit_receipt(route, q, owner=owner, + approval_tx_ids=list(receipt.protocol_state.get("approvalTxIds", [])), + source_tx_id=source_tx_id, receipt=observed, mint_mode=plan.mint_mode, + intended_recipient=plan.recipient) + + def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: + """One read-only refresh of an Ethereum source leg (brief §2.4 branches 1 and 3, plus xReserve SOURCE_CONFIRMING). + + ``SOURCE_APPROVAL_PENDING``: approval receipt → ``SOURCE_SUBMISSION_PENDING`` (or ``FAILED`` on revert). + ``SOURCE_CONFIRMING``: Hyperlane → ``DELIVERY_PENDING`` with the ``DispatchId`` message id; + xReserve → ``ATTESTATION_PENDING`` after re-verifying the ``DepositedToRemote`` event. + An unmined transaction returns the receipt unchanged. Never signs. + + Deviation from veil (deliberate): veil raises for a reverted Hyperlane/xReserve *source* + transaction but returns ``FAILED`` for a reverted approval. Here every reverted source + transaction observed at this stage becomes ``FAILED`` with ``protocol_state["sourceError"]`` — + one uniform rule that plan 4's ``get_status``/``wait`` can rely on without a protocol switch. + ``send()`` still raises on revert. + """ + route = self._route_for_plan(plan) + if receipt.protocol != plan.protocol or receipt.protocol_state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Receipt does not match the prepared route") + if receipt.status == Status.SOURCE_APPROVAL_PENDING: + approval_id = self._require_hash(receipt.id, "EVM approval transaction id") + observed = self.conn.get_receipt(approval_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM approval transaction reverted: {approval_id}") + return receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + if receipt.status == Status.SOURCE_CONFIRMING: + if route.protocol == "hyperlane": + return self._hyperlane_source_status(route, plan, receipt) + return self._xreserve_source_status(route, plan, receipt) + raise BridgeError("source_status refreshes SOURCE_APPROVAL_PENDING and SOURCE_CONFIRMING receipts only; " + "use bridge.get_status for later stages") + + # -- recovery ----------------------------------------------------------------------------- + + def _checkpoint_approvals(self, checkpoint: Checkpoint) -> list[str]: + approvals = list((checkpoint.source or {}).get("approvalTransactionIds", [])) + if any(not isinstance(a, str) or not _HASH_RE.fullmatch(a) for a in approvals): + raise CheckpointInvalidError("Bridge checkpoint contains an invalid approval transaction id") + return approvals + + def _approval_scan_block(self, approvals: list[str]) -> int | None: + """Highest block of a confirmed approval; an unresolved hash is skipped, a reverted one is an error.""" + block: int | None = None + for approval in approvals: + observed = self.conn.get_receipt(approval) + if observed is None: + continue + if int(observed["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {approval}") + number = int(observed["blockNumber"]) + block = number if block is None or number > block else block + return block + + def _scan_logs(self, address: str, from_block: int) -> list[Any]: + """Every log of *address* from *from_block* to the head, read in bounded ascending chunks. + + The head is read once so the scan terminates on a fixed range, and every request carries an + explicit ``fromBlock``/``toBlock``: an unbounded filter is what public RPCs reject or truncate, + and a truncated answer would silently read as "no dispatch/deposit was ever submitted". + """ + errors = _provider_errors() + chunk = self.log_scan_chunk_blocks + try: + latest = int(self.conn.w3.eth.block_number) + except errors as exc: + raise BridgeError(f"Could not read the current block number to bound a log scan of {address}: {exc}") from exc + logs: list[Any] = [] + start = from_block + while start <= latest: + end = min(start + chunk - 1, latest) + try: + logs.extend(self.conn.w3.eth.get_logs({"address": address, "fromBlock": start, "toBlock": end})) + except errors as exc: + raise BridgeError( + f"eth_getLogs failed for blocks {start}-{end} of {from_block}-{latest} on {address}: {exc}. " + f"Use a dedicated RPC endpoint, or a smaller EthModule(log_scan_chunk_blocks=...) " + f"than the current {chunk}.") from exc + start = end + 1 + return logs + + def _recover_hyperlane_from_history(self, route: Route, recipient32: bytes, receipt: Receipt, approvals: list[str], + *, required: bool) -> Receipt | None: + """Scan router ``SentTransferRemote`` logs after the last confirmed approval; sender and router must match.""" + from web3.exceptions import TransactionNotFound + + Web3 = _web3().Web3 + sender = receipt.protocol_state.get("sourceSender") + if not isinstance(sender, str) or not Web3.is_address(sender): + if required: + raise BridgeError("Cannot safely resume Hyperlane without the source account used by the approval") + return None + from_block = self._approval_scan_block(approvals) + if from_block is None: + if required: + raise BridgeError("Cannot safely resume Hyperlane because no confirmed approval block is available " + "for source history verification") + return None + amount = int(receipt.protocol_state["amountAtomic"]) + meta = self._hyperlane_metadata(route) # reuse the registry validator before touching the router + destination = meta.destination_domain + router = meta.router + warp = self._contract(router, WARP_ROUTE_ABI) + topic = Web3.keccak(text="SentTransferRemote(uint32,bytes32,uint256)") + candidates: list[str] = [] + for log in self._scan_logs(router, from_block): + if not log["topics"] or bytes(log["topics"][0]) != bytes(topic): + continue + args = warp.events.SentTransferRemote().process_log(log)["args"] + tx_hash = Web3.to_hex(log["transactionHash"]) + if (int(args["destination"]) == destination and bytes(args["recipient"]) == recipient32 + and int(args["amount"]) == amount and tx_hash not in candidates): + candidates.append(tx_hash) + matches: list[Receipt] = [] + for tx_hash in candidates: + try: + tx = self.conn.w3.eth.get_transaction(tx_hash) + except TransactionNotFound: + continue + observed = self.conn.get_receipt(tx_hash) + if (tx is None or observed is None or tx["to"] is None + or Web3.to_checksum_address(tx["from"]) != Web3.to_checksum_address(sender) + or Web3.to_checksum_address(tx["to"]) != router): + continue + if int(observed["status"]) == 0: + # Deliberately asymmetric with the xReserve scan below, and identical to veil: a + # reverted Hyperlane candidate raises (hyperlane/evm.ts) because sender+router+args + # already identify it as ours, while xreserve/evmToAleo.ts swallows a rejected + # candidate because the shared contract's logs are mostly other accounts' deposits. + raise BridgeError(f"EVM transaction reverted: {tx_hash}") + message_id = self._message_id_from_receipt(route, observed) + state = dict(receipt.protocol_state) + if message_id is not None: + state["messageId"] = message_id + matches.append(receipt.replace(id=message_id or tx_hash, status=Status.DELIVERY_PENDING, + source_tx_id=tx_hash, protocol_state=state)) + if len(matches) > 1: + raise BridgeError("Multiple matching Hyperlane dispatches were found; recovery cannot safely choose one source transaction") + return matches[0] if matches else None + + def _recover_hyperlane(self, route: Route, plan: Plan, checkpoint: Checkpoint, *, required: bool) -> Receipt: + Web3 = _web3().Web3 + recipient32 = encoding.aleo_address_to_bytes32(plan.recipient) + approvals = self._checkpoint_approvals(checkpoint) + sender = Web3.to_checksum_address(plan.sender) if plan.sender and Web3.is_address(plan.sender) else None + meta = self._hyperlane_metadata(route) + state = self._hyperlane_protocol_state(route, recipient_bytes32=recipient32, + destination_domain=meta.destination_domain, + native_value_atomic=0, amount_atomic=plan.amount_atomic, + approval_tx_ids=approvals, sender=sender) + transaction_id = (checkpoint.source or {}).get("transactionId") + if not transaction_id: + if not approvals: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted transaction") + pending = Receipt(id=approvals[-1], protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state) + observed = self.conn.get_receipt(approvals[-1]) + if observed is None: + if required: + raise BridgeError("Cannot safely resume Hyperlane because no confirmed approval block is available " + "for source history verification") + return pending + if int(observed["status"]) == 0: + return self._failed(pending, "sourceError", f"EVM approval transaction reverted: {approvals[-1]}") + recovered = self._recover_hyperlane_from_history(route, recipient32, pending, approvals, required=required) + return recovered or pending.replace(status=Status.SOURCE_SUBMISSION_PENDING) + transaction_id = self._require_hash(transaction_id, "source transaction id") + pending = Receipt(id=transaction_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=transaction_id, protocol_state=state) + observed = self._hyperlane_source_status(route, plan, pending) + if observed is not pending: + return observed + return self._recover_hyperlane_from_history(route, recipient32, pending, approvals, required=False) or observed + + def _recover_xreserve_from_history(self, route: Route, plan: Plan, q: _XReserveQuote, owner: str, approvals: list[str], + *, required: bool) -> Receipt | None: + """Scan xReserve logs after the last confirmed approval; a candidate matches only if every event field matches.""" + Web3 = _web3().Web3 + from_block = self._approval_scan_block(approvals) + if from_block is None: + if required: + raise BridgeError("Cannot safely resume xReserve because no confirmed approval block is available " + "for source history verification") + return None + hashes: list[str] = [] + for log in self._scan_logs(q.xreserve_contract, from_block): + tx_hash = Web3.to_hex(log["transactionHash"]) + if tx_hash not in hashes: + hashes.append(tx_hash) + matches: list[Receipt] = [] + for tx_hash in hashes: + observed = self.conn.get_receipt(tx_hash) + if observed is None: + continue + try: + matches.append(self._confirmed_deposit_receipt(route, q, owner=owner, approval_tx_ids=approvals, source_tx_id=tx_hash, + receipt=observed, mint_mode=plan.mint_mode, intended_recipient=plan.recipient)) + except BridgeError: + continue # other accounts' deposits share the contract; unrelated unless every field matches + if len(matches) > 1: + raise BridgeError("Multiple matching xReserve deposits were found; recovery cannot safely choose one source transaction") + return matches[0] if matches else None + + def _recover_xreserve(self, route: Route, plan: Plan, checkpoint: Checkpoint, *, required: bool) -> Receipt: + Web3 = _web3().Web3 + owner = self._observed_owner(plan, None) + approvals = self._checkpoint_approvals(checkpoint) + source = checkpoint.source or {} + stored_hook = source.get("hookData") + if stored_hook is not None and (not isinstance(stored_hook, str) or not re.fullmatch(r"0x[0-9a-fA-F]{130}", stored_hook)): + raise CheckpointInvalidError("Bridge checkpoint contains invalid xReserve hook data") + hook = bytes.fromhex(stored_hook[2:]) if stored_hook else encoding.xreserve_hook_data( + plan.mint_mode, plan.recipient, self.network, "0scalar") + meta = self._xreserve_metadata(route) # reuse the registry validator rather than trusting raw metadata + token = self.registry.asset(route.source_asset_id).locator + if token is None or token.kind != "evm-contract": + raise RouteUnavailableError(f"xReserve source token contract is missing: {route.id}") + q = _XReserveQuote( + xreserve_contract=meta.xreserve_contract, token=Web3.to_checksum_address(token.value), + source_chain_id=meta.source_chain_id, source_domain=meta.source_domain, remote_domain=meta.remote_domain, + remote_token_bytes32=meta.remote_token_bytes32, + remote_recipient_bytes32=self._xreserve_recipient_bytes32(route, meta, plan.recipient, plan.mint_mode), + amount_atomic=plan.amount_atomic, max_fee_atomic=meta.max_fee_atomic, hook_data=hook, + balance_atomic=0, allowance_atomic=0, bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) + state = self._xreserve_protocol_state(route, q, approval_tx_ids=approvals, sender=owner, mint_mode=plan.mint_mode, + intended_recipient=plan.recipient) + transaction_id = source.get("transactionId") + if not transaction_id: + if not approvals: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted transaction") + pending = Receipt(id=approvals[-1], protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state) + observed = self.conn.get_receipt(approvals[-1]) + if observed is None: + if required: + raise BridgeError("Cannot safely resume xReserve because no confirmed approval block is available " + "for source history verification") + return pending + if int(observed["status"]) == 0: + return self._failed(pending, "sourceError", f"EVM approval transaction reverted: {approvals[-1]}") + recovered = self._recover_xreserve_from_history(route, plan, q, owner, approvals, required=required) + return recovered or pending.replace(status=Status.SOURCE_SUBMISSION_PENDING) + transaction_id = self._require_hash(transaction_id, "xReserve source transaction id") + pending = Receipt(id=transaction_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=transaction_id, protocol_state=state) + observed = self._xreserve_source_status(route, plan, pending) + if observed is not pending: + return observed + return self._recover_xreserve_from_history(route, plan, q, owner, approvals, required=False) or observed + + def recover_source(self, plan: Plan, checkpoint: Checkpoint, *, required: bool = False) -> Receipt: + """Reconstruct an interrupted Ethereum source leg from a checkpoint without signing (brief §2.7, §3.1, §3.2). + + Approval-only checkpoints: observe the last approval; when confirmed, scan the router / + xReserve logs from its block for a matching dispatch or deposit and stop at + ``SOURCE_SUBMISSION_PENDING`` when none exists — recovery never moves funds. Checkpoints + with a source transaction are observed through ``source_status``. ``required=True`` (plan + 4's resume-before-dispatch mode) demands the scan actually run — a known sender and a + confirmed approval block — or raises, instead of quietly returning an approval-boundary + receipt. ``required=True`` only makes the INABILITY to scan fatal: once the scan actually + runs, a completed scan that matches zero dispatches/deposits is a valid answer ("nothing + was submitted yet"), not an error, and returns ``SOURCE_SUBMISSION_PENDING`` so ``resume`` + may re-authorize the send. + """ + if checkpoint.version != 1 or checkpoint.intent.get("bridgeProtocol") != plan.protocol or checkpoint.route.get("id") != plan.route_id: + raise CheckpointInvalidError("Bridge checkpoint does not match the prepared route") + if checkpoint.route.get("registryVersion") != self.registry.version: + raise RegistryVersionMismatchError( + f"Checkpoint uses registry {checkpoint.route.get('registryVersion')}; this client has {self.registry.version}") + if plan.protocol == "hyperlane" and checkpoint.destination: + raise CheckpointInvalidError("Hyperlane checkpoints must not carry a destination leg") + route = self._route_for_plan(plan) + self.assert_chain(route) + if route.protocol == "hyperlane": + return self._recover_hyperlane(route, plan, checkpoint, required=required) + if route.protocol == "xreserve": + return self._recover_xreserve(route, plan, checkpoint, required=required) + raise UnsupportedRouteError(f"No Ethereum recovery for protocol {route.protocol}") + + def _mailbox_address(self) -> str: + """The Hyperlane Mailbox deployed on this chain. + + Prefer a route that ORIGINATES here and passes the full metadata validator: its + ``mailboxAddress`` is the contract this chain's own dispatches go through, checksummed and + checked. Only if no such route exists do we fall back to any route that merely touches this + chain (whose ``mailboxAddress`` may be the remote one, and is unvalidated). + """ + routes = list(self.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self.bridge.environment)) + for route in routes: + if self.registry.asset(route.source_asset_id).chain_id != self.chain.id: + continue + try: + return self._hyperlane_metadata(route).mailbox + except (ConfigurationError, RouteUnavailableError): + continue + for route in routes: + chains = {self.registry.asset(route.source_asset_id).chain_id, self.registry.asset(route.destination_asset_id).chain_id} + mailbox = route.metadata.get("mailboxAddress") + if self.chain.id in chains and isinstance(mailbox, str): + return mailbox + raise UnsupportedRouteError(f"No Hyperlane Mailbox is configured for {self.chain.id} in registry {self.registry.version}") + + def is_delivered(self, message_id: str | bytes) -> bool: + """``Mailbox.delivered(bytes32)`` on this chain — the canonical Aleo → Ethereum delivery signal.""" + raw = bytes.fromhex(message_id[2:]) if isinstance(message_id, str) and message_id.startswith("0x") else message_id + if not isinstance(raw, (bytes, bytearray)) or len(raw) != 32: + raise BridgeError("Hyperlane delivery requires a 32-byte message id") + return bool(self._contract(self._mailbox_address(), MAILBOX_ABI).functions.delivered(bytes(raw)).call()) + + def balance(self, asset: Any, *, address: str | None = None) -> int: + """Atomic balance of ``asset`` (native via ``eth_getBalance``, ERC-20 via ``balanceOf``) for ``address`` or the connection's account.""" + target = self._asset(asset) + owner = self._owner(address) + if owner is None: + raise ConfigurationError("balance() needs an address: pass address= or configure a signer") + if target.locator is None: + raise UnsupportedRouteError(f"{target.id} has no on-chain locator") + if target.locator.kind == "native": + return int(self.conn.w3.eth.get_balance(owner)) + if target.locator.kind == "evm-contract": + return int(self._erc20(target.locator.value).functions.balanceOf(owner).call()) + raise UnsupportedRouteError(f"{target.id} is not an EVM asset") + + def _chain_assertion_route(self) -> Route: + """Any route originating on this chain with a usable ``sourceChainId``, used only to bind + ``assert_chain`` to the registry's notion of this chain (never touches contracts).""" + routes = [r for r in self.registry.routes(include_unavailable=True, environment=self.bridge.environment) + if self.registry.asset(r.source_asset_id).chain_id == self.chain.id + and isinstance(r.metadata.get("sourceChainId"), int)] + if not routes: + raise UnsupportedRouteError( + f"No Hyperlane or xReserve route with sourceChainId is configured for {self.chain.id}") + return routes[0] + + def chain_status(self) -> ChainStatus: + """Address, signing ability, and atomic balances of every registry asset on this chain (empty when read-only). + + Asserts the connected ``Web3``'s ``eth_chainId`` matches this chain's registry + ``sourceChainId`` first, so a connection pointed at the wrong network raises + ``ChainMismatchError`` instead of silently reading balances from the wrong chain. + """ + self.assert_chain(self._chain_assertion_route()) + address = self.conn.address + balances: dict[str, int] = {} + if address is not None: + for asset in self.registry.assets(chain=self.chain.id): + if asset.locator is not None and asset.locator.kind in ("native", "evm-contract"): + balances[asset.id] = self.balance(asset, address=address) + return ChainStatus(chain_id=self.chain.id, address=address, can_sign=self.conn.can_sign, balances=balances) + + +__all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py new file mode 100644 index 0000000..050f418 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -0,0 +1,193 @@ +"""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``. +""" +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from . import encoding as enc +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) + "]" + + +class FreezeList: + """``bridge.freezelist`` — reads a compliant token's frozen addresses and proves an address is not among them.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + def freeze_list_program(self, token_program: str) -> str: + """The freeze-list program backing *token_program* (usually a token program that imports it).""" + if token_program.endswith("freezelist.aleo"): + return token_program + from aleo.facade.errors import AleoError, ProgramNotFound + try: + imports = self._bridge.program(token_program).imports + except (ProgramNotFound, AleoError): + imports = [] + for dep in imports: + if str(dep).endswith("freezelist.aleo"): + return str(dep) + 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") + + def leaves(self, program: str) -> list[str]: + """Frozen addresses from *program*'s freeze-list ``freeze_list_index`` mapping (0..last inclusive), + with the zero-address sentinel dropped; ``[]`` when the list is empty/unreadable.""" + fl_program = self.freeze_list_program(program) + last = self._bridge.mapping_value(fl_program, FREEZE_LIST_LAST_INDEX_MAPPING, "true") + if last is None: + return [] + try: + 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 = [] + 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: + addresses.append(value) + return addresses + + def _verified_tree(self, fl_program: str, leaves: list[str]) -> list[int]: + tree = build_tree(generate_leaves(leaves), self._bridge.network) + 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") + computed_root = f"{tree[-1]}field" + if computed_root != on_chain_root: + raise ConfigurationError( + f"computed freeze-list root {computed_root} != on-chain root {on_chain_root} for {fl_program}; " + "refusing to build a proof") + return tree + + def tree(self, program: str) -> list[int]: + """Merkle tree over *program*'s frozen addresses, verified against the on-chain root when readable.""" + 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.""" + 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)) + + +__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", + "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", + "leaf_indices", "sibling_path"] diff --git a/bridge-sdk/python/aleo_bridge/hyperlane.py b/bridge-sdk/python/aleo_bridge/hyperlane.py new file mode 100644 index 0000000..bd03808 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/hyperlane.py @@ -0,0 +1,189 @@ +"""Aleo-origin Hyperlane warp routes (port of veil protocols/hyperlane/aleo.ts and utils/hyperlaneDelivery.ts). + +Seven-input ``transfer_remote`` with allowance slot 0 = live IGP payment; IGP quote from +``hyp_hook_manager.aleo/destination_gas_configs``; delivery read from ``hyp_mailbox.aleo/deliveries``. +""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +from . import encoding as enc +from ._calls import AleoCall +from .errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) +from .registry import Asset, Route +from .types import DispatchReceipt, GasQuote, Receipt, Status +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +MAX_U64 = (1 << 64) - 1 +GAS_QUOTE_SCALE = 10_000_000_000 # fixed by hyp_hook_manager.aleo post_dispatch +ZERO_GAS_LIMIT_FALLBACK = 50_000 +_GAS_FIELDS = ("gas_overhead", "exchange_rate", "gas_price") +_GAS_FIELD_RE = re.compile(r"\b(gas_overhead|exchange_rate|gas_price)\s*:\s*(\d+)u128") + + +def parse_gas_config(literal: str) -> dict[str, int]: + """``{ gas_overhead: 159337u128, exchange_rate: 402u128, gas_price: 1000000000u128 }`` → ints.""" + found = {m.group(1): int(m.group(2)) for m in _GAS_FIELD_RE.finditer(literal)} + missing = [f for f in _GAS_FIELDS if f not in found] + if missing: + raise ConfigurationError(f"Hyperlane destination gas configuration is malformed (missing {missing}): {literal!r}") + return found + + +def gas_config_key(route: Route) -> str: + """The ``destination_gas_configs`` key the hook manager reads at finalization.""" + return f"{{ igp: {route.meta_str('aleoMailboxDefaultHook')}, destination: {route.meta_int('aleoDestinationDomain')}u32 }}" + + +def compute_gas_payment(*, gas_limit: int, gas_overhead: int, gas_price: int, exchange_rate: int) -> int: + """Exact integer formula asserted on chain: ``(limit + overhead) * price * rate // 10^10`` as a positive u64.""" + payment = ((gas_limit + gas_overhead) * gas_price * exchange_rate) // GAS_QUOTE_SCALE + if payment <= 0 or payment > MAX_U64: + raise ConfigurationError(f"Hyperlane hook payment does not fit a positive u64: {payment}") + return payment + + +def _allowance(route: Route, index: int, amount: int | None) -> str: + value = str(amount) if amount is not None else route.meta_str(f"aleoAllowanceAmount{index}") + return f"{{ spender: {route.meta_str(f'aleoAllowanceSpender{index}')}, amount: {value}u64 }}" + + +class HyperlaneModule: + """``bridge.hyperlane`` — Aleo-side Hyperlane reads and the ``transfer_remote`` write.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + # ── route resolution ── + def _aleo_asset(self, asset: Any) -> Asset: + resolved = self._bridge.registry.asset(asset) + if resolved.chain_id != self._bridge.aleo_chain().id: + raise UnsupportedRouteError( + f"{resolved.id} is not an Aleo asset on {self._bridge.environment}; Aleo-origin Hyperlane transfers " + "start from aleo/eth, aleo/wbtc, aleo/usdt or aleo/sol (use bridge.eth / bridge.sol for other origins)") + return resolved + + def _route_for(self, asset_or_route: Any) -> Route: + if isinstance(asset_or_route, Route): + route = asset_or_route + if route.protocol != "hyperlane": + raise UnsupportedRouteError(f"Not a Hyperlane route: {route.id}") + self._aleo_asset(route.source_asset_id) + return route + return self.outbound_route(asset_or_route) + + def outbound_route(self, asset: Any) -> Route: + """The single active, non-placeholder Hyperlane route leaving this Aleo asset.""" + source = self._aleo_asset(asset) + candidates = [r for r in self._bridge.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self._bridge.environment) + if r.source_asset_id == source.id] + if not candidates: + raise RouteNotFoundError(f"No Hyperlane route leaves {source.id}") + executable = [r for r in candidates if r.active and r.metadata.get("aleoPlaceholderConfiguration") is not True] + if not executable: + detail = ", ".join(f"{r.id} ({r.availability})" for r in candidates) + raise RouteUnavailableError(f"Hyperlane routes from {source.id} are not executable: {detail}") + if len(executable) > 1: + raise AmbiguousRouteError(f"Several active Hyperlane routes leave {source.id}: {[r.id for r in executable]}") + return executable[0] + + # ── reads ── + def quote_gas_payment(self, asset: Any) -> GasQuote: + """Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving.""" + route = self._route_for(asset) + literal = self._bridge.mapping_value(route.meta_str("aleoHookManagerProgram"), "destination_gas_configs", + gas_config_key(route)) + if literal is None: + raise ConfigurationError(f"Hyperlane destination gas configuration is missing on chain: {route.id}") + config = parse_gas_config(literal) + if config["exchange_rate"] == 0 or config["gas_price"] == 0: + raise ConfigurationError(f"Hyperlane destination gas configuration is unpriced: {route.id}") + gas_limit = int(route.meta_str("aleoRemoteRouterGas")) or ZERO_GAS_LIMIT_FALLBACK + payment = compute_gas_payment(gas_limit=gas_limit, gas_overhead=config["gas_overhead"], + gas_price=config["gas_price"], exchange_rate=config["exchange_rate"]) + return GasQuote(route.id, gas_limit, config["gas_overhead"], config["gas_price"], config["exchange_rate"], payment) + + def _mailbox_program(self) -> str: + for route in self._bridge.registry.routes(protocol="hyperlane", include_unavailable=True, environment=self._bridge.environment): + program = route.metadata.get("aleoMailboxProgram") + if isinstance(program, str) and program: + return program + raise ConfigurationError(f"No Aleo Hyperlane mailbox program is configured for {self._bridge.environment}") + + def is_delivered(self, message_id: "str | bytes") -> bool: + """Whether ``hyp_mailbox.aleo/deliveries`` holds the message (mapping presence is the acceptance signal).""" + try: + raw = enc.hex_to_bytes(message_id, 32) + except (ValueError, InvalidRecipientError) as exc: + raise ConfigurationError("Hyperlane delivery requires a 32-byte message id") from exc + return self._bridge.mapping_value(self._mailbox_program(), "deliveries", enc.hyperlane_delivery_key(raw)) is not None + + # ── transfer_remote ── + def build_transfer_remote_inputs(self, route: Route, *, recipient: str, amount_atomic: int, + gas_payment_microcredits: int, decimals: tuple[int, int]) -> list[str]: + """The seven ``transfer_remote`` literals (brief §3.3). Pure; works for placeholder routes too (inspection only).""" + if isinstance(gas_payment_microcredits, bool) or not isinstance(gas_payment_microcredits, int) \ + or not (0 < gas_payment_microcredits <= MAX_U64): + raise ConfigurationError(f"gas_payment_microcredits must be a positive u64: {gas_payment_microcredits}") + if amount_atomic <= 0: + raise InvalidAmountError("Bridge transfer amount must be greater than zero") + registry = self._bridge.registry + destination = registry.asset(route.destination_asset_id) + destination_chain = registry.chain(destination.chain_id) + if not destination.matches_address(recipient): + raise InvalidRecipientError(f"Recipient does not match the {destination.chain_id} address format: {recipient}") + if destination_chain.family == "evm": + limbs = enc.evm_address_to_hyperlane_recipient(recipient) + elif destination_chain.family == "solana": + limbs = enc.solana_address_to_hyperlane_recipient(recipient) + else: + raise UnsupportedRouteError(f"Unsupported Hyperlane destination family {destination_chain.family!r}: {route.id}") + local_decimals, remote_decimals = decimals + domain = route.meta_int("aleoDestinationDomain") + app_metadata = (f"{{ token_type: {route.meta_str('aleoTokenType')}u8, token_owner: {route.meta_str('aleoTokenOwner')}, " + f"ism: {route.meta_str('aleoIsm')}, hook: {route.meta_str('aleoHook')}, " + f"token_id: {route.meta_str('aleoTokenId')}, local_decimals: {local_decimals}u8, " + f"remote_decimals: {remote_decimals}u8 }}") + mailbox_state = (f"{{ default_hook: {route.meta_str('aleoMailboxDefaultHook')}, " + f"required_hook: {route.meta_str('aleoMailboxRequiredHook')} }}") + remote_router = (f"{{ domain: {domain}u32, recipient: {route.meta_str('aleoRemoteRouterRecipient')}, " + f"gas: {route.meta_str('aleoRemoteRouterGas')}u128 }}") + allowances = "[" + ", ".join(_allowance(route, i, gas_payment_microcredits if i == 0 else None) for i in range(4)) + "]" + return [app_metadata, mailbox_state, remote_router, f"{domain}u32", enc.u128_pair_literal(limbs), + f"{amount_atomic}u128", allowances] + + def transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + as_signer: bool = False, gas_payment_microcredits: int | None = None) -> AleoCall[DispatchReceipt]: + """Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the + lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again.""" + route = self.outbound_route(asset) + registry = self._bridge.registry + source, destination = registry.asset(route.source_asset_id), registry.asset(route.destination_asset_id) + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + parse_decimal_amount(format_decimal_amount(atomic, source.decimals), destination.decimals) # veil prepare(): representable on both sides + payment = gas_payment_microcredits if gas_payment_microcredits is not None \ + else self.quote_gas_payment(route).payment_microcredits + decimals = (route.meta_int("aleoLocalDecimals", source.decimals), route.meta_int("aleoRemoteDecimals", destination.decimals)) + inputs = self.build_transfer_remote_inputs(route, recipient=recipient, amount_atomic=atomic, + gas_payment_microcredits=payment, decimals=decimals) + program = route.meta_str("aleoRouterProgram") + function = "transfer_remote_as_signer" if as_signer else "transfer_remote" + + def build(tx_id: str, _outputs: list[str]) -> DispatchReceipt: + receipt = Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": route.id, "sourceProgram": program, "sourceFunction": function, + "amountAtomic": str(atomic), "recipient": recipient, + "gasPaymentMicrocredits": str(payment)}) + return DispatchReceipt(transaction_id=tx_id, route_id=route.id, message_id=None, amount_atomic=atomic, receipt=receipt) + + return self._bridge._call(program, function, inputs, build) + + +__all__ = ["GAS_QUOTE_SCALE", "MAX_U64", "ZERO_GAS_LIMIT_FALLBACK", "HyperlaneModule", "compute_gas_payment", + "gas_config_key", "parse_gas_config"] diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py new file mode 100644 index 0000000..d2a567b --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -0,0 +1,1389 @@ +"""Tier 1 lifecycle verbs — veil's ``quote → execute → wait`` with ``recover`` / +``resume`` / ``complete`` driven by ``Progress.next``. + +Every function takes the registry (and, from later tasks on, the +:class:`~aleo_bridge.client.Bridge`) and touches only its public surface, so the +unit suite can run them against plain fakes without a network. ``Bridge`` binds +thin methods of the same names. + +Order of the file follows the caller journey: planning (``prepare``), pricing +(``quote``), committing funds (``execute``), observing (``get_status``, +``wait``), and recovery (``recover``, ``resume``, ``complete``) — this task +only adds ``prepare`` and the ``resolve_route`` helper every later verb shares. +""" +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass, replace +from typing import Any, Callable + +from . import _sealevel +from ._calls import is_duplicate_submission +from .encoding import HOOK_DATA_BYTES +from ._plan import build_plan +from .checkpoint import Checkpoint, create_checkpoint +from .errors import ( + AttestationError, + BridgeError, + CheckpointInvalidError, + ConfigurationError, + DeliveryUnknownError, + InvalidAmountError, + InvalidRecipientError, + NotResumableError, + PollingTimeoutError, + RegistryVersionMismatchError, + RouteUnavailableError, + UnsupportedRouteError, +) +from .registry import Asset, Chain, Registry, Route +from .types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, + Fee, Plan, Progress, Quote, Receipt, Status, to_progress) +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount + +MINT_MODES = ("public", "record", "private") + + +# ── Route resolution (invariant 1) ──────────────────────────────────────────── + +@dataclass(frozen=True) +class ResolvedRoute: + """The live registry entries behind one plan — re-resolved on every verb.""" + + route: Route + source_asset: Asset + destination_asset: Asset + source_chain: Chain + destination_chain: Chain + + +def resolve_route(registry: Registry, plan: Plan) -> ResolvedRoute: + """Re-resolve *plan* against the live registry; refuse stale or altered plans. + + Raises :class:`RegistryVersionMismatchError` when the plan was built from a + different registry version (re-quote to fix) and + :class:`CheckpointInvalidError` when its route topology no longer matches. + """ + if plan.registry_version != registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has " + f"{registry.version}. Re-run quote() to rebuild the plan.") + route = registry.route(plan.route_id) + if (route.protocol != plan.protocol + or route.source_asset_id != plan.source_asset_id + or route.destination_asset_id != plan.destination_asset_id): + raise CheckpointInvalidError( + f"Plan route {plan.route_id} does not match the configured registry " + "(protocol or asset pair differs). Re-run quote().") + source = registry.asset(route.source_asset_id) + destination = registry.asset(route.destination_asset_id) + return ResolvedRoute(route, source, destination, + registry.chain(source.chain_id), registry.chain(destination.chain_id)) + + +def _require_active(route: Route) -> None: + if route.availability != "active": + raise RouteUnavailableError( + f"Route {route.id} is '{route.availability}': it is listed by the registry " + "but cannot move funds until its deployment is reviewed. Pick an active route " + "(bridge.registry.routes()).") + + +# ── prepare ─────────────────────────────────────────────────────────────────── + +def prepare(registry: Registry, *, source, destination, amount=None, amount_atomic=None, + recipient: str, sender: str | None = None, protocol: str | None = None, + mint_mode: str = "public") -> Plan: + """Describe how *amount* of *source* moves to *destination* — pure, no network. + + Resolves the single non-disabled route for the asset pair (``protocol`` + disambiguates), validates the mint mode (non-public only for xReserve into + Aleo), parses the amount with the source decimals AND re-parses it with the + destination decimals so no precision is silently lost, regex-checks the + recipient against the destination chain, then hands off to + :func:`aleo_bridge._plan.build_plan` for the step list — the same builder + ``bridge.eth.*`` / ``bridge.sol.*`` use, so a caller-supplied plan and a + ``prepare()``-built one are always identical for the same route and amount. + Nothing is signed and no chain is contacted; ``quote`` adds live prices on + top of this. + """ + src = registry.asset(source) + dst = registry.asset(destination) + route = registry.find_route(src.id, dst.id, protocol) + dst_chain = registry.chain(dst.chain_id) + + if mint_mode not in MINT_MODES: + raise ConfigurationError(f"mint_mode must be one of {MINT_MODES}, got {mint_mode!r}") + if mint_mode != "public" and dst_chain.family != "aleo": + raise ConfigurationError( + "Aleo mint mode is only valid when the destination chain is Aleo") + if route.protocol != "xreserve" and mint_mode != "public": + raise ConfigurationError( + "record and private mint modes are only supported by xReserve routes") + + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=src.decimals) + if atomic <= 0: + raise InvalidAmountError("Bridge transfer amount must be greater than zero") + parse_decimal_amount(format_decimal_amount(atomic, src.decimals), dst.decimals) # destination precision check + + if dst.address_regex and not re.fullmatch(dst.address_regex, recipient): + raise InvalidRecipientError( + f"Recipient {recipient!r} does not match the {dst.chain_id} address format " + f"({dst.address_regex})") + + return build_plan(registry, route, amount_atomic=atomic, recipient=recipient, + sender=sender, mint_mode=mint_mode) + + +# ── Connection helpers ──────────────────────────────────────────────────────── + +def _module(bridge, name: str): + """``bridge.eth`` / ``bridge.sol`` or a ConfigurationError that says how to fix it. + + Checks the ``ethereum``/``solana`` connection attribute FIRST: on the real + ``Bridge``, ``eth``/``sol`` are properties that themselves raise + ``ConfigurationError`` when unconfigured, so ``getattr(bridge, name, None)`` + would never see the ``None`` default — it would let that raise propagate + with the property's own (less specific) message instead of this one. + """ + conn = getattr(bridge, "ethereum" if name == "eth" else "solana", None) + if conn is None: + chain, extra, env = (("Ethereum", "evm", "ETHEREUM_RPC_URL / EVM_PRIVATE_KEY") if name == "eth" + else ("Solana", "solana", "SOLANA_RPC_URL / SOLANA_PRIVATE_KEY")) + raise ConfigurationError( + f"This transfer needs a configured {chain} connection: pass " + f"{'ethereum' if name == 'eth' else 'solana'}= to Bridge(...) (pip install " + f"'aleo-bridge-sdk[{extra}]') or set {env} for Bridge.from_env().") + return getattr(bridge, name) + + +def _credits_asset_id(chain: Chain) -> str: + return f"{chain.id}/aleo" + + +# ── quote ───────────────────────────────────────────────────────────────────── + +def quote(bridge, *, source, destination, amount=None, amount_atomic=None, recipient: str, + sender: str | None = None, protocol: str | None = None, mint_mode: str = "public", + secret_nonce: str = "0scalar") -> Quote: + """Price a transfer: ``prepare`` + the source-side live read for the route kind. + + Returns one of ``EvmHyperlaneQuote`` / ``SolanaHyperlaneQuote`` / + ``AleoHyperlaneQuote`` / ``EvmXReserveQuote`` / ``AleoXReserveQuote`` + (``quote.kind``), each carrying the canonical ``plan`` that ``execute`` takes. + Aleo-origin xReserve quotes make no network call (fixed withdrawal fee). + Nothing is signed. + + Dispatches to ``bridge.eth``/``bridge.sol`` with ``plan=`` (never a re-derived + ``asset``/``recipient``/``amount_atomic`` form): those modules re-resolve the + route from the plan and validate it against the live registry themselves. The + quote they return is then re-stamped with this function's own ``plan`` (via + ``replace``) so the plan on the result is always exactly what ``prepare()`` + built, regardless of what the module attached internally. + """ + plan = prepare(bridge.registry, source=source, destination=destination, amount=amount, + amount_atomic=amount_atomic, recipient=recipient, sender=sender, + protocol=protocol, mint_mode=mint_mode) + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + family = resolved.source_chain.family + + if plan.protocol == "hyperlane" and family == "evm": + q = _module(bridge, "eth").quote_transfer_remote(plan=plan) + return replace(q, plan=plan) + if plan.protocol == "hyperlane" and family == "solana": + q = _module(bridge, "sol").quote_transfer_remote(plan=plan) + return replace(q, plan=plan) + if plan.protocol == "hyperlane" and family == "aleo": + gas = bridge.hyperlane.quote_gas_payment(plan.source_asset_id) + fee = Fee(kind="protocol", chain_id=resolved.source_chain.id, + asset_id=_credits_asset_id(resolved.source_chain), + amount=format_decimal_amount(gas.payment_microcredits, 6), estimated=True) + return AleoHyperlaneQuote(kind="aleo-hyperlane", plan=plan, fees=(fee,), amount_out=plan.amount, + gas_limit=gas.gas_limit, gas_overhead=gas.gas_overhead, + gas_price=gas.gas_price, exchange_rate=gas.exchange_rate, + payment_microcredits=gas.payment_microcredits) + if plan.protocol == "xreserve" and family == "evm": + q = _module(bridge, "eth").quote_deposit_usdc(plan=plan, secret_nonce=secret_nonce) + return replace(q, plan=plan) + if plan.protocol == "xreserve" and family == "aleo": + raw = resolved.route.metadata.get("withdrawalFeeAtomic") + if not isinstance(raw, str) or not raw.isdigit(): + raise RouteUnavailableError(f"xReserve withdrawal fee is missing or invalid: {plan.route_id}") + fee_atomic = int(raw) + decimals = resolved.source_asset.decimals + fee_human = format_decimal_amount(fee_atomic, decimals) + if plan.amount_atomic <= fee_atomic: + raise InvalidAmountError( + f"xReserve burn amount must exceed the {fee_human} {resolved.source_asset.symbol} " + f"withdrawal fee (got {plan.amount})") + return AleoXReserveQuote( + kind="aleo-xreserve", plan=plan, + fees=(Fee(kind="protocol", chain_id=resolved.source_chain.id, asset_id=resolved.source_asset.id, + amount=fee_human, estimated=False),), + amount_out=format_decimal_amount(plan.amount_atomic - fee_atomic, decimals), + withdrawal_fee_atomic=fee_atomic) + raise UnsupportedRouteError( + f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") + + +# ── Checkpoint emission ─────────────────────────────────────────────────────── + +def _persist(bridge, checkpoint: Checkpoint, receipt: Receipt, *, previous_id: str | None = None) -> None: + """Mirror *checkpoint* into the bound store: save (or drop, if terminal) BEFORE superseding + the previous id — never the reverse, so a crash between the two steps still leaves a valid + record for the transfer rather than a moment where the store holds neither. + """ + store = getattr(bridge, "checkpoints", None) + if store is None: + return + if receipt.status in TERMINAL: + store.delete(checkpoint.id) + else: + store.save(checkpoint) + if previous_id is not None and previous_id != checkpoint.id: + store.delete(previous_id) + + +class _Emitter: + """Turns receipts into checkpoints: caller callback first, then the bound store. + + Two channels feed it — a protocol module's own ``on_checkpoint`` (which hands over a + ``Checkpoint`` it has already reduced, and saves ITSELF only after this call returns) and + ``execute``'s own emission once the send returns. A boundary that arrives through both is + handed to the caller once: the two reductions compare equal, being the same receipt reduced + against the same plan. + + A module-emitted checkpoint supersedes the previous id before the module has actually saved + the new one — deleting the old id here (save-then-delete, brief §review item 6) would leave a + window where the store holds neither if it crashed. So that delete is parked as + ``_pending_supersede`` and only carried out once we know the module's save has landed: at the + start of the next emission (module-emitted or not — the loop that owns the module has already + returned from its ``store.save`` by then) or, failing that, when ``execute`` calls + :meth:`finalize` after its own last receipt is persisted. + """ + + def __init__(self, bridge, plan: Plan, on_checkpoint: Callable | None) -> None: + self._bridge, self._plan, self._cb = bridge, plan, on_checkpoint + self._last_id: str | None = None + self._last: Checkpoint | None = None + self._pending_supersede: str | None = None + + def __call__(self, receipt) -> Checkpoint: + self._flush_pending() + module_emitted = isinstance(receipt, Checkpoint) + checkpoint = receipt if module_emitted else create_checkpoint(self._plan, receipt, self._bridge.registry) + if checkpoint != self._last: + if hasattr(self._bridge, "events"): + # test hook: FakeBridge records the ordering of proving/checkpoint/broadcast + label = receipt.status.value if isinstance(receipt, Receipt) else "module" + self._bridge.events.append((f"checkpoint:{label}", checkpoint.id)) + if self._cb is not None: + self._cb(checkpoint) # the caller's own callback: errors are theirs + if module_emitted: + self._pending_supersede = self._last_id # module saves this one itself, after we return + else: + _persist(self._bridge, checkpoint, receipt, previous_id=self._last_id) + self._last_id, self._last = checkpoint.id, checkpoint + return checkpoint + + def _flush_pending(self) -> None: + if self._pending_supersede is None: + return + pending, self._pending_supersede = self._pending_supersede, None + if pending == self._last_id: + return + store = getattr(self._bridge, "checkpoints", None) + if store is not None: + store.delete(pending) + + def finalize(self) -> None: + """Drop any still-pending supersede. Call once execute()'s final receipt is persisted.""" + self._flush_pending() + + +# ── Execution helpers ───────────────────────────────────────────────────────── + +def _assert_sender(plan: Plan, address: str | None, *, family: str) -> None: + """Refuse a plan prepared for a different account than the one that would sign it. + + EVM addresses are hex and their checksum casing carries no identity, so they compare + case-insensitively; Solana addresses are base58, where case IS part of the address. + """ + if not plan.sender or not address: + return + same = plan.sender.lower() == address.lower() if family == "evm" else plan.sender == address + if not same: + raise ConfigurationError( + f"Plan sender {plan.sender} does not match the connected account {address}. " + "Re-quote with sender=None or the connection's own address.") + + +def _connected_aleo_address(bridge) -> str | None: + """``bridge.aleo_address()``, or None when no account is configured to sign with. + + A read-only facade cannot sign an Aleo leg anyway, so a missing account is not this check's + problem to raise on — it just means there is nothing to compare the plan's sender against. + """ + try: + return bridge.aleo_address() + except (ConfigurationError, AttributeError): + return None + + +def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> int | None: + """The recipient's destination balance, or None when there is no reader for it. + + Only read when the destination connection IS the recipient (there is no per-address balance + read in the module contracts); otherwise return None rather than baseline the wrong account. + + A transport failure is NOT swallowed here (Task 6 review item 8): ``get_status`` branch 6 uses + this balance as the delivery SIGNAL, and a swallowed RPC error would read as "not delivered + yet" forever instead of being retried by ``wait``'s transient classifier. The one caller that + genuinely cannot afford to raise — ``execute``'s advisory pre-broadcast baseline — does the + swallowing itself, in :func:`_delivery_verification`. + """ + chain, asset = resolved.destination_chain, resolved.destination_asset + if chain.family == "evm": + conn = getattr(bridge, "ethereum", None) + if (conn is None or not conn.address + or conn.address.lower() != plan.recipient.lower() + or asset.locator is None or asset.locator.kind not in ("native", "evm-contract")): + return None + return int(bridge.eth.balance(asset.id)) + if chain.family == "solana": + conn = getattr(bridge, "solana", None) + if (conn is None or conn.address != plan.recipient + or asset.locator is None or asset.locator.kind != "native"): + return None + return int(bridge.sol.balance()) + return None # Aleo private records / token mappings: protocol signal instead + + +def _delivery_verification(bridge, plan: Plan, resolved: ResolvedRoute) -> dict[str, str]: + """``execute``'s advisory delivery baseline — an unreadable balance is simply omitted. + + The best-effort swallow lives at THIS call site and not inside ``_read_destination_balance`` + (Task 6 review item 8): here the balance is a nice-to-have baseline written into a checkpoint + before broadcast, so a flaky RPC must never block funds movement; in ``get_status`` branch 6 + the same read is the delivery signal and must raise. + """ + try: + before = _read_destination_balance(bridge, plan, resolved) + except Exception: # noqa: BLE001 — advisory read only + return {} + if before is None: + return {} + expected = parse_decimal_amount(plan.amount, resolved.destination_asset.decimals) + return {"destinationBalanceBeforeAtomic": str(before), + "expectedDestinationIncreaseAtomic": str(expected)} + + +def _prepare_aleo(call, proving: str): + if proving == "delegate": + return call.delegate_prepared() + if proving == "local": + return call.prove() + raise ConfigurationError(f"proving must be 'delegate' (DPS) or 'local', got {proving!r}") + + +def _run_aleo_leg(bridge, plan: Plan, call, *, proving: str, emit: _Emitter, + extra_state: dict[str, Any]) -> Receipt: + """Invariant 3: prove → checkpoint the exact transaction → broadcast → checkpoint the id.""" + prepared = _prepare_aleo(call, proving) + emit(Receipt(id=prepared.transaction_id, protocol=plan.protocol, + status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared.serialized, + **extra_state})) + result = call.submit_prepared(prepared, wait=False) # polling is wait()'s job, not execute()'s + receipt: Receipt = result.receipt + receipt = receipt.replace(id=prepared.transaction_id, status=Status.SOURCE_CONFIRMING, + source_tx_id=prepared.transaction_id, + protocol_state={**receipt.protocol_state, "routeId": plan.route_id, **extra_state}) + emit(receipt) + return receipt + + +def _send_call(call, emit: _Emitter, poll_seconds: float, timeout_seconds: float) -> Receipt: + result = call.send(wait=True, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, + on_checkpoint=emit) + receipt: Receipt = result.receipt + emit(receipt) + return receipt + + +def _aleo_hyperlane_mode(mode: str | None) -> bool: + if mode is None or mode == "caller": + return False + if mode == "signer": + return True + raise ConfigurationError(f"Aleo Hyperlane mode must be 'caller' or 'signer', got {mode!r}") + + +def _xreserve_burn_mode(mode: str | None) -> str: + if mode is None: + return "private" + if mode in ("private", "public", "public-as-signer"): + return mode + raise ConfigurationError( + f"Aleo xReserve mode must be 'private', 'public' or 'public-as-signer', got {mode!r}") + + +def _mint_secret(plan: Plan, secret_nonce: str | None) -> str: + """The secret the EVM deposit commits to. A private mint must supply its own. + + ``secret_nonce`` is not a ``Plan`` field and the SDK never stores it: a private deposit that + quietly fell back to the ``"0scalar"`` default would commit to a hook nobody can reproduce, + and ``complete`` needs the same value again to mint the record. + """ + if plan.mint_mode == "private": + if not secret_nonce: + raise ConfigurationError( + "a secret_nonce is required for a private xReserve mint: the deposit commits to " + "(recipient, secret_nonce) and complete() needs the same value again — keep it, " + "the SDK never stores it") + return secret_nonce + return secret_nonce or "0scalar" + + +# ── 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, + 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``. + + Runs every source-chain leg for ``plan`` — approval(s) → deposit / dispatch / burn — emitting + 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 + ``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. + + EVM and Solana legs are dispatched through the module's own ``plan=`` surface, so the route, + registry version, sender and every plan field are re-validated by the module that builds the + transaction. Returns after broadcast; call ``wait`` to observe acceptance and delivery. Once + the irreversible step is broadcast, recover from the checkpoint — never re-run ``execute``. + """ + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + family = resolved.source_chain.family + emit = _Emitter(bridge, plan, on_checkpoint) + + if plan.protocol == "hyperlane" and family == "evm": + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family=family) + call = eth.transfer_remote(plan=plan) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) + + if plan.protocol == "hyperlane" and family == "solana": + sol = _module(bridge, "sol") + _assert_sender(plan, bridge.solana.address, family=family) + call = sol.transfer_remote(plan=plan) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) + + if plan.protocol == "hyperlane" and family == "aleo": + _assert_sender(plan, _connected_aleo_address(bridge), family="aleo") + as_signer = _aleo_hyperlane_mode(mode) + verification = _delivery_verification(bridge, plan, resolved) + gas = gas_payment_microcredits + if gas is None: + gas = bridge.hyperlane.quote_gas_payment(plan.source_asset_id).payment_microcredits + call = bridge.hyperlane.transfer_remote(plan.source_asset_id, plan.recipient, + amount_atomic=plan.amount_atomic, as_signer=as_signer, + gas_payment_microcredits=gas) + receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state=verification) + emit.finalize() + return to_progress(plan, receipt) + + if plan.protocol == "xreserve" and family == "evm": + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family=family) + nonce = _mint_secret(plan, secret_nonce) + call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) + + if plan.protocol == "xreserve" and family == "aleo": + _assert_sender(plan, _connected_aleo_address(bridge), family="aleo") + burn_mode = _xreserve_burn_mode(mode) + call = bridge.xreserve.burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, + record=record, merkle_proof=merkle_proof) + receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state={}) + emit.finalize() + return to_progress(plan, receipt) + + raise UnsupportedRouteError(f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") + + +# ── Aleo transaction status ─────────────────────────────────────────────────── + +def aleo_transaction_status(bridge, tx_id: str) -> tuple[str, str | None]: + """``("accepted" | "rejected" | "pending", error)`` from the confirmed-transaction envelope. + + Reads ``GET /transaction/confirmed/{id}`` through the facade. A 404 + (``TransactionNotFound``) means not confirmed yet → ``pending``. The + envelope's top-level ``status`` is the node's verdict; it carries no reason, + so the error text is generic. + """ + from aleo.facade.errors import TransactionNotFound + try: + confirmed = bridge.aleo.network.get_confirmed_transaction(tx_id) + except TransactionNotFound: + return "pending", None + status = confirmed.get("status") if isinstance(confirmed, dict) else getattr(confirmed, "status", None) + if status == "accepted": + return "accepted", None + if status == "rejected": + return "rejected", f"Aleo transaction {tx_id} was rejected by the network" + return "pending", None + + +_HEX = re.compile(r"^0x[0-9a-fA-F]*$") +_MESSAGE_ID_RE = re.compile(r"^0x[0-9a-fA-F]{64}$") + + +def _hex_bytes(value: Any, *, length: int | None = None) -> bytes | None: + """Strict ``0x`` hex → bytes, or None when malformed / wrong length.""" + if not isinstance(value, str) or not _HEX.match(value) or len(value) % 2: + return None + data = bytes.fromhex(value[2:]) + if length is not None and len(data) != length: + return None + return data + + +def _check_receipt(plan: Plan, receipt: Receipt) -> None: + if receipt.protocol != plan.protocol or receipt.protocol_state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Bridge receipt does not match the prepared route") + + +def _clear_action(receipt: Receipt, **changes) -> Receipt: + return receipt.replace(next_action=None, **changes) + + +def _message_id(receipt: Receipt) -> str | None: + """``protocol_state["messageId"]`` first; else ``receipt.id`` when it is itself a message id. + + Solana and EVM Hyperlane receipts carry the id in ``protocol_state["messageId"]`` once + known; a receipt that instead carries the message id AS its own ``id`` (some Aleo-origin + shapes) falls back to that, but only when it is an exact 32-byte ``0x`` hex string — never a + signature or an unrelated transaction hash of a different width. Never falls back when + ``receipt.id == receipt.source_tx_id``: an EVM Hyperlane receipt whose DispatchId log was + unreadable carries the source transaction hash as its id, which is a same-shaped ``0x`` hex + string but is NOT a message id. + """ + state = receipt.protocol_state + message_id = state.get("messageId") + if isinstance(message_id, str) and message_id: + return message_id + if (isinstance(receipt.id, str) and receipt.id != receipt.source_tx_id + and _MESSAGE_ID_RE.fullmatch(receipt.id)): + return receipt.id + return None + + +# ── get_status ──────────────────────────────────────────────────────────────── + +def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: + """One refresh of the transfer's state — no polling, no signing. + + Ports veil's branch table in order: terminal receipts (``COMPLETED``/``FAILED``/``EXPIRED``) + return untouched; EVM approvals and source confirmations delegate to the chain module (never + called for any other status — both ``EthModule.source_status`` and ``SolModule.source_status`` + raise otherwise); Aleo source acceptance moves to ``DELIVERY_PENDING``; Hyperlane delivery is + read from the destination Mailbox by message id (filling a missing Solana message id from the + source transaction's logs first, never handing a signature to ``is_delivered``), or from the + destination balance baseline for Aleo-origin routes; inbound xReserve reads the destination + nullifier FIRST (invariant 6), then Circle's attestation (private mode stops at + ``DESTINATION_ACTION_REQUIRED`` with ``next_action`` = ``{"kind": "xreserve-private-mint", + "chainId": ...}``), then the private mint's acceptance. Returns the SAME object when nothing + changed. + + A Solana transport failure inside ``SolModule.source_status`` (or the message-id log read) is + not swallowed here: a single refresh may raise on a flaky public RPC, and retrying with backoff + is ``wait``'s job, not this function's. + """ + resolved = resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + if receipt.status in TERMINAL: + return receipt + route, src, dst = resolved.route, resolved.source_chain, resolved.destination_chain + state = receipt.protocol_state + + # 1. EVM approval → wallet boundary + if receipt.status is Status.SOURCE_APPROVAL_PENDING and src.family == "evm": + return _module(bridge, "eth").source_status(plan, receipt) + + # 2. Aleo source acceptance is the irreversible boundary + if receipt.status is Status.SOURCE_CONFIRMING and src.family == "aleo": + if not receipt.source_tx_id: + raise CheckpointInvalidError("Bridge receipt is missing its Aleo source transaction id") + verdict, error = aleo_transaction_status(bridge, receipt.source_tx_id) + if verdict == "accepted": + return _clear_action(receipt, status=Status.DELIVERY_PENDING) + if verdict == "rejected": + return _clear_action(receipt, status=Status.FAILED, + protocol_state={**state, "sourceError": error}) + return receipt + + # 3/4. Hyperlane source confirmation on EVM / Solana (extracts messageId) + if receipt.status is Status.SOURCE_CONFIRMING and route.protocol == "hyperlane": + if src.family == "evm": + return _module(bridge, "eth").source_status(plan, receipt) + if src.family == "solana": + return _module(bridge, "sol").source_status(plan, receipt) + + # 5. Hyperlane delivery: the destination Mailbox is canonical + message_id = _message_id(receipt) + if (message_id is None and receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" + and src.family == "solana" and state.get("messageIdUnavailable") and receipt.source_tx_id): + sol = _module(bridge, "sol") # missing connection must raise, not be swallowed + try: + logs = sol._transaction_logs(receipt.source_tx_id) + except Exception: # noqa: BLE001 — advisory fill-in only + logs = None + filled = None if logs is None else _sealevel.extract_hyperlane_message_id(logs) + if filled is not None: + new_state = {k: v for k, v in state.items() if k != "messageIdUnavailable"} + new_state["messageId"] = filled + receipt = receipt.replace(id=filled, protocol_state=new_state) + state, message_id = new_state, filled + # else: still unavailable — fall through unchanged; never hand the signature to is_delivered + + if (receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" + and message_id is not None and dst.family in ("aleo", "evm")): + delivered = (bridge.hyperlane.is_delivered(message_id) if dst.family == "aleo" + else _module(bridge, "eth").is_delivered(message_id)) + return _clear_action(receipt, status=Status.COMPLETED) if delivered else receipt + + # 6. Aleo-origin Hyperlane without a message id: destination balance baseline + if receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" and src.family == "aleo": + before, expected = state.get("destinationBalanceBeforeAtomic"), state.get("expectedDestinationIncreaseAtomic") + if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): + return receipt + current = _read_destination_balance(bridge, plan, resolved) + if current is None: + raise DeliveryUnknownError( + f"No destination balance reader is configured for {dst.id}: bind the {dst.id} connection " + "whose address is the recipient, or confirm delivery out of band") + if current < int(before) + int(expected): + return receipt + return _clear_action(receipt, status=Status.COMPLETED) + + # 7. Other Hyperlane states are observed elsewhere + if route.protocol == "hyperlane": + return receipt + + # 8. xReserve Aleo→EVM: Circle exposes no canonical delivery query + if (receipt.status is Status.DELIVERY_PENDING and route.protocol == "xreserve" + and src.family == "aleo" and dst.family == "evm"): + return receipt + + # 9. Everything else that is not inbound xReserve + if route.protocol != "xreserve" or src.family != "evm" or dst.family != "aleo": + raise UnsupportedRouteError("Status refresh is not implemented for this bridge route") + + # 10. xReserve EVM→Aleo — destination nullifier first (invariant 6) + if receipt.status in (Status.ATTESTATION_PENDING, Status.DELIVERY_PENDING, Status.DESTINATION_ACTION_REQUIRED): + nonce = state.get("nonce") + if not isinstance(nonce, str): + payload = _hex_bytes(state.get("payload")) + if payload is not None: + from .encoding import xreserve_nonce_from_payload + nonce = "0x" + xreserve_nonce_from_payload(payload).hex() + if isinstance(nonce, str) and nonce and bridge.xreserve.is_delivered(nonce, route=route): + return _clear_action(receipt, status=Status.COMPLETED) + + if receipt.status is Status.SOURCE_CONFIRMING: + return _module(bridge, "eth").source_status(plan, receipt) + + if receipt.status is Status.ATTESTATION_PENDING: + message_hash = state.get("messageHash") + if _hex_bytes(message_hash, length=32) is None: + raise CheckpointInvalidError("xReserve receipt is missing its Circle message hash") + attestation = bridge.xreserve.get_attestation(message_hash, route=route) + if attestation is None: + return receipt + with_att = {**state, "attestation": "0x" + attestation.attestation.hex()} + if plan.mint_mode != "private": + return receipt.replace(status=Status.DELIVERY_PENDING, protocol_state=with_att) + return receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, protocol_state=with_att, + next_action={"kind": "xreserve-private-mint", "chainId": dst.id}) + + if receipt.status is Status.DESTINATION_ACTION_REQUIRED: + return receipt + + if receipt.status is Status.DESTINATION_CONFIRMING: + if not receipt.destination_tx_id: + raise CheckpointInvalidError("xReserve receipt is missing its Aleo destination transaction id") + verdict, error = aleo_transaction_status(bridge, receipt.destination_tx_id) + if verdict == "accepted": + return _clear_action(receipt, status=Status.COMPLETED) + if verdict == "rejected": + return _clear_action(receipt, status=Status.FAILED, + protocol_state={**state, "destinationError": error}) + return receipt + + return receipt + + +# ── wait ────────────────────────────────────────────────────────────────────── + +def _track(bridge, plan: Plan, previous: Receipt, current: Receipt) -> None: + """Mirror an observed status change into the bound store (no caller callback).""" + if getattr(bridge, "checkpoints", None) is None: + return + _persist(bridge, create_checkpoint(plan, current, bridge.registry), current, previous_id=previous.id) + + +def _status_set(values) -> set[Status]: + try: + return {v if isinstance(v, Status) else Status(v) for v in values} + except ValueError as exc: + raise ConfigurationError(f"wait(until=...) contains an unknown status: {exc}") from exc + + +_TRANSIENT_BRIDGE_ERROR_RE = re.compile(r"HTTP status (429|5\d\d)|request failed:") + + +def _is_transient_error(exc: Exception) -> bool: + """``wait``'s retry classifier: a network/RPC hiccup vs. a real problem that must propagate. + + Transient: ``requests.RequestException`` (any ``requests``-based transport), ``aleo.facade. + errors.AleoNetworkError`` (the Aleo facade), a ``BridgeError`` whose message matches an HTTP + 429/5xx or a wrapped "request failed:" transport error (``SolanaRpcClient``), or a web3 + provider/connection error. Everything else — including programming errors, ``BridgeError``s + about an actual on-chain failure, ``CheckpointInvalidError``, ``RouteUnavailableError`` — + propagates immediately. + """ + try: + import requests + if isinstance(exc, requests.RequestException): + return True + except ImportError: + pass + try: + from aleo.facade.errors import AleoNetworkError + if isinstance(exc, AleoNetworkError): + return True + except ImportError: + pass + try: + from web3.exceptions import ProviderConnectionError + if isinstance(exc, ProviderConnectionError): + return True + except ImportError: + if type(exc).__name__ == "ProviderConnectionError": # web3 extra not installed here + return True + if isinstance(exc, BridgeError) and _TRANSIENT_BRIDGE_ERROR_RE.search(str(exc)): + return True + return False + + +def wait(bridge, progress: Progress, *, until=None, poll_seconds: float = 15.0, + timeout_seconds: float = 1200.0, on_update: Callable[[Progress], Any] | None = None, + on_error: Callable[[Exception], Any] | None = None, max_consecutive_errors: int = 5) -> Progress: + """Poll ``get_status`` until the transfer needs the caller or finishes. + + Always stops at the caller boundaries — ``SOURCE_SUBMISSION_PENDING`` (→ ``resume``), + ``DESTINATION_ACTION_REQUIRED`` (→ ``complete``), ``COMPLETED``, ``FAILED``, ``EXPIRED`` — plus + any statuses in ``until`` (a ``Status`` or its name; ``until=[]`` is a ``ConfigurationError``, + an unknown name too). Returns immediately when ``progress.next != "wait"`` or the receipt is + already at a stop. ``on_update`` fires only when the receipt changed, never on a retry. + ``poll_seconds`` is floored at 0.1 unless exactly 0; negative ``poll_seconds``/``timeout_seconds`` + is a ``ConfigurationError``. + + A ``get_status`` call that raises a transient error (flaky RPC/HTTP transport — see + :func:`_is_transient_error`) is retried with the normal poll interval, up to + ``max_consecutive_errors`` (default 5) consecutive failures before the last one is re-raised; + ``on_error`` fires on each tolerated retry so callers can log them. A non-transient error + propagates immediately, on the first attempt. + + Hitting ``timeout_seconds`` raises ``PollingTimeoutError`` carrying the last ``status`` and + ``progress`` — a timeout is NOT a failure (invariant 5): the transfer is still in flight; call + ``wait`` again or ``recover`` later. + """ + if until is not None and len(until) == 0: + raise ConfigurationError("wait(until=[]) has nothing to stop at: pass at least one Status or omit until") + plan, receipt = progress.plan, progress.receipt + resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + stops = set(CALLER_BOUNDARIES) | _status_set(until or ()) + current = to_progress(plan, receipt) + if current.next != "wait" or receipt.status in stops: + return current + if poll_seconds < 0 or timeout_seconds < 0: + raise ConfigurationError("poll_seconds and timeout_seconds must be non-negative") + interval = 0.0 if poll_seconds == 0 else max(0.1, float(poll_seconds)) + deadline = time.monotonic() + timeout_seconds + updated = receipt + consecutive_errors = 0 + while True: + try: + nxt = get_status(bridge, plan, updated) + except Exception as exc: + if not _is_transient_error(exc): + raise + consecutive_errors += 1 + if consecutive_errors > max_consecutive_errors: + raise + if on_error is not None: + on_error(exc) + if time.monotonic() >= deadline: + raise PollingTimeoutError( + f"Bridge status polling timed out in state {updated.status.value}; the transfer is " + "still in flight — call wait() again or recover() from the last checkpoint. This is " + "not a failure.", status=updated.status, progress=to_progress(plan, updated)) from exc + time.sleep(interval) + continue + consecutive_errors = 0 + if nxt != updated: + _track(bridge, plan, updated, nxt) + if on_update is not None: + on_update(to_progress(plan, nxt)) + updated = nxt + if updated.status in stops: + return to_progress(plan, updated) + if time.monotonic() >= deadline: + raise PollingTimeoutError( + f"Bridge status polling timed out in state {updated.status.value}; the transfer is still " + "in flight — call wait() again or recover() from the last checkpoint. This is not a failure.", + status=updated.status, progress=to_progress(plan, updated)) + time.sleep(interval) + + +__all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "complete", "execute", "get_status", + "is_duplicate_broadcast_error", "prepare", "progress_from_checkpoint", "quote", "recover", + "resolve_route", "resume", "submit_serialized", "wait"] + + +# ── recover ─────────────────────────────────────────────────────────────────── + +def _coerce_checkpoint(value): + if isinstance(value, Checkpoint): + return value + if isinstance(value, str): + return Checkpoint.from_json(value) + if isinstance(value, dict): + return Checkpoint.from_dict(value) + raise CheckpointInvalidError(f"recover() takes a Checkpoint, its dict, or its JSON; got {type(value).__name__}") + + +def _plan_from_intent(registry: Registry, intent: dict[str, Any]) -> Plan: + """Rebuild the ``Plan`` behind a checkpoint by re-running ``prepare`` on its intent. + + ``prepare`` is only ever a thin validating wrapper around ``_plan.build_plan`` + (proven field-identical for every active route — see + ``tests/test_prepare.py::test_prepare_equals_build_plan_for_every_active_route``), so a + recovered plan is exactly what the original ``prepare()``/``quote()`` call produced and + passes ``EthModule``/``SolModule``'s field-by-field ``plan=`` checks. + """ + try: + return prepare(registry, + source=(intent["source"]["chain"], intent["source"]["asset"]), + destination=(intent["destination"]["chain"], intent["destination"]["asset"]), + amount=intent["amount"], recipient=intent["recipient"], sender=intent.get("sender"), + protocol=intent.get("bridgeProtocol"), mint_mode=intent.get("mintMode", "public")) + except (KeyError, TypeError) as exc: + raise CheckpointInvalidError(f"Bridge checkpoint intent is incomplete: missing {exc}") from exc + + +def _assert_prepared_id(serialized: Any, expected_id: str, what: str = "prepared Aleo transaction") -> str: + """The serialized transaction's ``id`` must equal the saved id — never substitute bytes.""" + if not isinstance(serialized, str) or not serialized: + raise CheckpointInvalidError(f"Bridge checkpoint contains an invalid {what} (empty)") + try: + decoded = json.loads(serialized) + except json.JSONDecodeError as exc: + raise CheckpointInvalidError(f"Bridge checkpoint contains an invalid {what}: not JSON") from exc + tx_id = decoded.get("id") if isinstance(decoded, dict) else None + if not isinstance(tx_id, str) or tx_id != expected_id: + raise CheckpointInvalidError(f"Bridge checkpoint {what} id does not match its payload " + f"({tx_id!r} != {expected_id!r})") + return tx_id + + +def _finish(bridge, plan: Plan, receipt: Receipt, checkpoint_id: str) -> Progress: + """Reduce *receipt* to ``Progress``, dropping the checkpoint (keyed on ``checkpoint_id`` — + the checkpoint's OWN id, never ``receipt.id``: for Solana and EVM Hyperlane the checkpoint's + id is the source transaction id while the receipt's own id flips to the message id once + confirmed) from the bound store once the transfer reaches a terminal status. + """ + store = getattr(bridge, "checkpoints", None) + if store is not None and receipt.status in TERMINAL: + store.delete(checkpoint_id) + return to_progress(plan, receipt) + + +def _verification_from_delivery(dv: dict[str, Any]) -> dict[str, str]: + """Validate and translate a checkpoint's ``deliveryVerification`` block (Aleo-origin Hyperlane + only); ``{}`` when absent. Raises :class:`CheckpointInvalidError` on a malformed block (missing + key or non-digit value) rather than ``KeyError`` — the block came from a stored file, not a + live read, so it is untrusted input.""" + if not dv: + return {} + before, expected = dv.get("balanceBeforeAtomic"), dv.get("expectedIncreaseAtomic") + if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): + raise CheckpointInvalidError("Bridge checkpoint contains invalid destination balance verification state") + return {"destinationBalanceBeforeAtomic": before, "expectedDestinationIncreaseAtomic": expected} + + +def _reconstruct_source_receipt(plan: Plan, resolved: ResolvedRoute, cp: Checkpoint, + verification: dict[str, str]) -> Receipt: + """Build the pre-refresh source ``Receipt`` purely from a checkpoint's stored fields — no + network, no signing. Shared by ``recover`` (which then reads live chain state to refine + non-``SOURCE_SUBMISSION_PENDING`` results) and the fully offline ``progress_from_checkpoint`` + (which stops here), so the two can never drift apart on what a checkpoint alone can tell you. + + Mirrors ``create_checkpoint``'s own allowlist: a ``preparedTransaction`` with no + ``transactionId`` is an unbroadcast Aleo leg (``SOURCE_SUBMISSION_PENDING`` — next: resume); + a ``transactionId`` alone (any chain family) is a submitted, still-confirming leg + (``SOURCE_CONFIRMING``); an EVM leg with only approvals and no ``transactionId`` yet is + waiting on its deposit/dispatch (``SOURCE_SUBMISSION_PENDING`` too — the same "call resume()" + signal; ``resume()`` re-verifies against live chain history before repeating anything, so an + offline guess here is never unsafe); anything else has nothing to build a receipt from. + """ + src = resolved.source_chain + source = cp.source or {} + approvals = [a for a in (source.get("approvalTransactionIds") or []) if isinstance(a, str)] + + if src.family == "aleo": + prepared = source.get("preparedTransaction") + if prepared and not source.get("transactionId"): + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for a prepared Aleo source route") + tx_id = _assert_prepared_id(prepared.get("serializedTransaction"), str(prepared.get("transactionId"))) + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared["serializedTransaction"], + **verification}) + tx_id = source.get("transactionId") + if not tx_id: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for an Aleo source route") + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": plan.route_id, **verification}) + + if src.family == "solana": + tx_id = source.get("transactionId") + if not tx_id: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for a Solana source route") + blockhash, last_valid = source.get("blockhash"), source.get("lastValidBlockHeight") + if (blockhash is not None or last_valid is not None) and ( + not isinstance(blockhash, str) or not blockhash + or not isinstance(last_valid, str) or not last_valid.isdigit()): + raise CheckpointInvalidError("Bridge checkpoint contains an invalid Solana blockhash lifetime") + state: dict[str, Any] = {"routeId": plan.route_id} + if isinstance(blockhash, str) and isinstance(last_valid, str): + state.update(blockhash=blockhash, lastValidBlockHeight=last_valid) + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state=state) + + if src.family == "evm": + tx_id = source.get("transactionId") + if tx_id: + state: dict[str, Any] = {"routeId": plan.route_id} + if approvals: + state["approvalTxIds"] = approvals + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state=state) + if approvals: + return Receipt(id=approvals[-1], protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": approvals}) + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + + raise UnsupportedRouteError(f"Bridge checkpoint recovery is not implemented for source family {src.family!r}") + + +def _apply_destination_overlay(resolved: ResolvedRoute, cp: Checkpoint, receipt: Receipt) -> Receipt: + """Offline-only: fold a checkpoint's own destination fields (inbound xReserve) into *receipt* + without any network read — a submitted destination transaction id becomes + ``DESTINATION_CONFIRMING``, an unbroadcast prepared one becomes ``DESTINATION_ACTION_REQUIRED`` + (ready for ``complete()``). Never validates either against live chain state the way ``recover`` + does over the wire (that would need the Circle attestation, which a checkpoint never stores); + ``complete()``/``resume()`` re-verify before acting, so an offline-optimistic guess here is + never unsafe — only ever a prompt to call the verb that actually checks. + """ + destination = cp.destination or {} + if not destination: + return receipt + if resolved.route.protocol != "xreserve" or resolved.destination_chain.family != "aleo": + raise CheckpointInvalidError( + "Bridge checkpoint contains a destination transaction that is invalid for this route") + prepared_dest = destination.get("preparedTransaction") + if prepared_dest and destination.get("transactionId"): + raise CheckpointInvalidError( + "Bridge checkpoint cannot contain both prepared and submitted destination transactions") + if destination.get("transactionId"): + receipt = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=destination["transactionId"]) + if prepared_dest: + tx_id = _assert_prepared_id(prepared_dest.get("serializedTransaction"), str(prepared_dest.get("transactionId")), + "prepared Aleo destination transaction") + receipt = receipt.replace(id=tx_id, status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": resolved.destination_chain.id}, + protocol_state={**receipt.protocol_state, + "preparedDestinationTransaction": prepared_dest["serializedTransaction"]}) + return receipt + + +def progress_from_checkpoint(registry: Registry, checkpoint) -> Progress: + """Pure, fully offline reconstruction of a checkpoint's ``Progress`` — no network, no signing. + + Used by ``Bridge.pending()`` instead of ``recover`` so that listing every in-flight transfer + never depends on any chain being reachable (one unreachable RPC must never hide every other + transfer). Rebuilds the ``Plan`` from the checkpoint's own intent and validates it against the + live registry exactly like ``recover`` (still raises on a bad format, version, or route + mismatch — those mean the record cannot be interpreted at all). From there, everything is + built purely from the checkpoint's stored fields (:func:`_reconstruct_source_receipt` / + :func:`_apply_destination_overlay`) — the same reconstruction ``recover`` performs before its + own live refresh. A checkpoint whose stored fields cannot be interpreted after that point + (e.g. no submitted or prepared source transaction at all) folds into a ``Progress`` with + ``next == "failed"`` and ``error`` set, instead of raising — so one malformed record can never + hide the others in a ``pending()`` listing. + """ + cp = _coerce_checkpoint(checkpoint) + if cp.version != 1 or not cp.intent or not cp.route: + raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") + plan = _plan_from_intent(registry, cp.intent) + if cp.route.get("registryVersion") != plan.registry_version: + raise RegistryVersionMismatchError( + f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " + f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") + if cp.route.get("id") != plan.route_id: + raise CheckpointInvalidError( + f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") + resolved = resolve_route(registry, plan) + try: + verification = _verification_from_delivery(cp.delivery_verification or {}) + receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) + receipt = _apply_destination_overlay(resolved, cp, receipt) + except BridgeError as exc: + receipt = Receipt(id=cp.id, protocol=plan.protocol, status=Status.FAILED, + protocol_state={"routeId": plan.route_id, "sourceError": str(exc)}) + return to_progress(plan, receipt) + + +def recover(bridge, checkpoint) -> Progress: + """Rebuild a transfer's ``Progress`` from a saved checkpoint — reads only, never signs. + + Accepts a ``Checkpoint``, its dict, or its JSON. Re-runs ``prepare`` on the + saved intent against the LIVE registry, then checks the route id and registry + version (``CheckpointInvalidError`` / ``RegistryVersionMismatchError``). + Aleo source: a proved-but-unbroadcast transaction yields ``next == "resume"`` + with no network read; a submitted one gets exactly one ``get_status`` from + ``SOURCE_CONFIRMING``. Solana: validates the blockhash pair and reads the + signature status. EVM: delegates to ``bridge.eth.recover_source`` (log + scan); inbound xReserve additionally restores a submitted or prepared + private mint. The result's ``next`` tells the caller what to do. + """ + cp = _coerce_checkpoint(checkpoint) + if cp.version != 1 or not cp.intent or not cp.route: + raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") + plan = _plan_from_intent(bridge.registry, cp.intent) + if cp.route.get("registryVersion") != plan.registry_version: + raise RegistryVersionMismatchError( + f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " + f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") + if cp.route.get("id") != plan.route_id: + raise CheckpointInvalidError( + f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") + resolved = resolve_route(bridge.registry, plan) + src, dst = resolved.source_chain, resolved.destination_chain + verification = _verification_from_delivery(cp.delivery_verification or {}) + + if src.family in ("aleo", "solana"): + receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) + if receipt.status is Status.SOURCE_SUBMISSION_PENDING: + return to_progress(plan, receipt) # Aleo prepared, unbroadcast: no network read + receipt = get_status(bridge, plan, receipt) + return _finish(bridge, plan, receipt, cp.id) + + if resolved.route.protocol == "hyperlane" and src.family == "evm": + if cp.destination: + raise CheckpointInvalidError( + "Bridge checkpoint contains a destination transaction that is invalid for this Hyperlane route") + receipt = _module(bridge, "eth").recover_source(plan, cp, required=False) + return _finish(bridge, plan, receipt, cp.id) + + if resolved.route.protocol != "xreserve" or src.family != "evm" or dst.family != "aleo": + raise UnsupportedRouteError("Bridge checkpoint recovery is not implemented for this route") + + receipt = _module(bridge, "eth").recover_source(plan, cp, required=False) + destination = cp.destination or {} + prepared_dest = destination.get("preparedTransaction") + if prepared_dest and destination.get("transactionId"): + raise CheckpointInvalidError( + "Bridge checkpoint cannot contain both prepared and submitted destination transactions") + if prepared_dest: + _assert_prepared_id(prepared_dest.get("serializedTransaction"), str(prepared_dest.get("transactionId")), + "prepared Aleo destination transaction") + if destination.get("transactionId"): + receipt = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=destination["transactionId"]) + if receipt.status in (Status.ATTESTATION_PENDING, Status.DESTINATION_CONFIRMING): + receipt = get_status(bridge, plan, receipt) + if prepared_dest: + if receipt.status is not Status.DESTINATION_ACTION_REQUIRED: + raise CheckpointInvalidError( + "Prepared destination transaction is no longer valid for the recovered bridge state") + receipt = receipt.replace(id=str(prepared_dest["transactionId"]), + protocol_state={**receipt.protocol_state, + "preparedDestinationTransaction": prepared_dest["serializedTransaction"]}) + return _finish(bridge, plan, receipt, cp.id) + + +# ── Idempotent Aleo rebroadcast (invariant 3) ───────────────────────────────── + +#: Plan 1's duplicate-broadcast classifier, re-exported under the lifecycle's own name. +#: +#: It is deliberately NOT reimplemented here: ``AleoCall.submit_prepared`` already applies exactly +#: this rule to every prepared broadcast, and two rules that could ever disagree about "is this a +#: duplicate?" is one rule too many for a funds-critical path. Only the node's *"already exists"* +#: answer (ledger or mempool) means "this exact transaction is already known"; ``duplicate serial +#: number`` / ``duplicate output id`` mean a DIFFERENT transaction collided with this one's records +#: and must surface as failures — and they do, because they never say "already exists". +is_duplicate_broadcast_error = is_duplicate_submission + + +def submit_serialized(bridge, serialized: str, expected_id: str) -> str: + """Broadcast an already-proved transaction; a duplicate answer is success, not a failure. + + Returns the transaction id the node acknowledged, which must be *expected_id* — the id of the + exact bytes that were broadcast. A node that answers with a different id has accepted something + this transfer never checkpointed, so it is refused rather than recorded as its transaction. + """ + try: + submitted = str(bridge.aleo.network.submit_transaction(serialized)).strip().strip('"') + except Exception as exc: # noqa: BLE001 — the node's error type varies by transport + if is_duplicate_broadcast_error(exc): + return expected_id # the earlier broadcast won the race: nothing left to do + raise + if submitted != expected_id: + raise CheckpointInvalidError( + f"Aleo node acknowledged transaction {submitted}; expected {expected_id}. The prepared " + "bytes and the node's answer disagree — do not resend; inspect both ids first.") + return submitted + + +# ── resume ──────────────────────────────────────────────────────────────────── + +def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, + secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0, + proving: str = "delegate") -> Progress: + """Finish the source leg an interruption left unsubmitted — never repeats an irreversible step. + + Requires ``progress.next == "resume"`` (status ``SOURCE_SUBMISSION_PENDING``); anything else is + a :class:`~aleo_bridge.errors.NotResumableError` pointing at ``wait``/``recover``. In particular + a ``SOURCE_APPROVAL_PENDING`` receipt is NOT resumable directly: call ``recover`` first, which + observes the approval and yields the ``SOURCE_SUBMISSION_PENDING`` progress this verb takes. + + Aleo source: rebroadcasts the checkpointed transaction byte-for-byte, after checking that the + serialized payload's own id matches the saved one — a duplicate-transaction answer means the + first broadcast won the race and counts as success. The bytes are then dropped from the + receipt. Nothing is re-proved, so the transfer can only ever exist once on chain. + + EVM source: re-scans source history from the confirmed approval + (``bridge.eth.recover_source(plan, checkpoint, required=True)``) and, only when that scan proves + no deposit/dispatch exists yet, re-quotes and authorizes the single remaining transaction + through the module's own ``plan=`` surface. Two guards ported from veil refuse rather than + guess: the re-quoted hook data must equal the hook the checkpointed approval committed to (so a + private mint can never be re-hooked to a commitment its recipient cannot open), and the + allowance must still cover the deposit (a vanished allowance means something else spent it, and + re-approving is a second irreversible step ``resume`` does not own). A confirmed approval is + never repeated; the ids already recorded are carried into the new receipt. + + Solana source: ``SolCall`` has no approval step and no pre-broadcast state to continue, so there + is nothing to resume — ``recover``/``wait`` observe the signature instead. + + ``secret_nonce`` is mandatory when ``plan.mint_mode == "private"``, and is checked before any + RPC: the SDK never stored it, and a silent ``"0scalar"`` fallback would commit the deposit to a + hook nobody can open. ``proving`` is accepted for symmetry with ``execute``/``complete`` and is + never used — no resume path ever proves anything: an Aleo leg rebroadcasts bytes that were + already proved, and an EVM leg has no proofs at all. + """ + plan, receipt = progress.plan, progress.receipt + if progress.next != "resume" or receipt.status is not Status.SOURCE_SUBMISSION_PENDING: + raise NotResumableError( + "Bridge progress has no source submission to resume (next must be 'resume' at " + "SOURCE_SUBMISSION_PENDING); call wait() or recover() to refresh it") + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + _check_receipt(plan, receipt) + emit = _Emitter(bridge, plan, on_checkpoint) + state = receipt.protocol_state + family = resolved.source_chain.family + + if family == "aleo": + serialized = state.get("preparedTransaction") + if not isinstance(serialized, str) or not serialized: + raise NotResumableError( + "Prepared Aleo transfer is missing its serialized transaction: resume() rebroadcasts " + "the exact proved bytes and never re-proves. Recover from the checkpoint written " + "between proving and broadcast, or start the transfer over if none exists.") + tx_id = _assert_prepared_id(serialized, receipt.id) + submit_serialized(bridge, serialized, tx_id) + new_state: dict[str, Any] = {"routeId": plan.route_id} + for key in ("destinationBalanceBeforeAtomic", "expectedDestinationIncreaseAtomic"): + if isinstance(state.get(key), str): + new_state[key] = state[key] + submitted = Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, protocol_state=new_state) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + if family == "solana": + raise NotResumableError( + "Solana source legs have no resumable state: the transfer is signed and broadcast in one " + "step, so nothing is ever left to submit. Call recover() or wait() to observe the " + "signature instead.") + + if family != "evm": + raise UnsupportedRouteError(f"Source resumption is not implemented for {resolved.source_chain.id}") + + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family="evm") + is_xreserve = resolved.route.protocol == "xreserve" + nonce = _mint_secret(plan, secret_nonce) if is_xreserve else None # before any RPC + saved_hook = state.get("hookData") + if is_xreserve and _hex_bytes(saved_hook, length=HOOK_DATA_BYTES) is None: + # Without the hook the approval committed to there is nothing to compare the re-quote + # against, so the guard below would silently pass and the deposit could be re-hooked to a + # different commitment. Refuse here, before any RPC, rather than resume half-blind. + raise NotResumableError( + "This transfer's checkpoint carries no xReserve hook data (a 65-byte 0x hex string); " + "recover() and re-quote instead of resuming — resume() will not re-derive the hook the " + "approval committed to") + + recovered = eth.recover_source(plan, create_checkpoint(plan, receipt, bridge.registry), required=True) + if recovered.status is not Status.SOURCE_SUBMISSION_PENDING: + emit(recovered) # history already holds the irreversible step + emit.finalize() + return to_progress(plan, recovered) + + if is_xreserve: + quoted = eth.quote_deposit_usdc(plan=plan, secret_nonce=nonce) + if saved_hook.lower() != ("0x" + quoted.hook_data.hex()).lower(): # always runs: validated above + raise NotResumableError( + "The re-quoted hook data does not match the hook this transfer's approval committed " + "to: the secret nonce differs from the one used at execute(). Pass that same " + "secret_nonce — depositing under another hook mints to a commitment the recipient " + "can never open.") + else: + quoted = eth.quote_transfer_remote(plan=plan) + if quoted.approval_required: + raise NotResumableError( + "The approval recorded for this transfer no longer covers it: its allowance is gone. " + "Inspect Ethereum source history before starting another transfer — resume() will not " + "issue a second approval.") + + call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) if is_xreserve else eth.transfer_remote(plan=plan) + result = call.send(wait=True, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, + on_checkpoint=emit) + submitted = result.receipt + prior = [a for a in (state.get("approvalTxIds") or []) if isinstance(a, str)] + approvals = prior + [a for a in (submitted.protocol_state.get("approvalTxIds") or []) if a not in prior] + if approvals != list(submitted.protocol_state.get("approvalTxIds") or []): + submitted = submitted.replace(protocol_state={**submitted.protocol_state, "approvalTxIds": approvals}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + +# ── complete ────────────────────────────────────────────────────────────────── + +def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, + on_checkpoint: Callable | None = None, proving: str = "delegate") -> Progress: + """Submit the one user-signed Aleo transaction a private USDCx mint needs. + + Requires ``progress.next == "complete"`` — Circle has attested the deposit and the receipt + carries ``next_action == {"kind": "xreserve-private-mint", "chainId": }``. The + persisted payload (305 bytes), message hash (32 bytes) and attestation hex are re-validated + first, then one of two paths runs: + + * a ``preparedDestinationTransaction`` left by an earlier interrupted attempt is rebroadcast + byte-for-byte (a duplicate answer is success, and no ``secret_nonce`` is needed — those bytes + are already proved), or + * ``bridge.xreserve.private_mint`` builds the mint — re-verifying on the way that + ``(recipient, secret_nonce)`` really opens the attested hook-data commitment, so a wrong nonce + never reaches proving — which is then proved, checkpointed BEFORE broadcast, and broadcast. + + The source deposit is never repeated, and the secret nonce, the attestation and the hook data + are never written to a receipt, a checkpoint or a ``Progress``. ``secret_nonce`` must be the + value used at ``execute``; it is required for a private plan on the proving path + (``ConfigurationError``, raised before any RPC). + """ + if progress.next != "complete": + raise NotResumableError( + "Bridge progress has no destination action to complete (next must be 'complete'); call " + "wait() to refresh it — the Circle attestation may still be pending") + plan, receipt = progress.plan, progress.receipt + # Deliberately no _require_active here (unlike resume): by the time a transfer reaches + # DESTINATION_ACTION_REQUIRED the USDC is already deposited on Ethereum, and refusing the mint + # because the registry has since parked the route would strand it. The proving path still hits + # XReserveModule's own availability check; a rebroadcast of already-proved bytes needs none. + resolved = resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + action = receipt.next_action or {} + if (receipt.status is not Status.DESTINATION_ACTION_REQUIRED + or action.get("kind") != "xreserve-private-mint" + or action.get("chainId") != resolved.destination_chain.id): + raise NotResumableError( + "Bridge receipt carries no supported destination action: complete() only finishes an " + "xReserve private mint, on this transfer's own destination chain") + if (resolved.route.protocol != "xreserve" or resolved.source_chain.family != "evm" + or resolved.destination_chain.family != "aleo"): + raise UnsupportedRouteError("Destination completion is not implemented for this bridge route") + + state = receipt.protocol_state + payload = _hex_bytes(state.get("payload"), length=305) + message_hash = _hex_bytes(state.get("messageHash"), length=32) + attestation = _hex_bytes(state.get("attestation")) + if payload is None or message_hash is None or not attestation: + raise AttestationError( + "Ready xReserve receipt is missing its validated Circle attestation (a 305-byte payload, " + "a 32-byte messageHash and the attestation hex); refresh it with wait()") + emit = _Emitter(bridge, plan, on_checkpoint) + + prepared_dest = state.get("preparedDestinationTransaction") + if prepared_dest is not None: + tx_id = _assert_prepared_id(prepared_dest, receipt.id, "prepared Aleo destination transaction") + submit_serialized(bridge, prepared_dest, tx_id) + submitted = receipt.replace( + status=Status.DESTINATION_CONFIRMING, destination_tx_id=tx_id, next_action=None, + protocol_state={k: v for k, v in state.items() if k != "preparedDestinationTransaction"}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + nonce = _mint_secret(plan, secret_nonce) # before any RPC + att = Attestation(payload=payload, message_hash=message_hash, attestation=attestation, status="complete") + call = bridge.xreserve.private_mint(att, plan.recipient, secret_nonce=nonce, route=resolved.route) + prepared = _prepare_aleo(call, proving) + # invariant 3: the exact bytes live in a checkpoint before the network can ever see them + emit(receipt.replace(id=prepared.transaction_id, + protocol_state={**state, "preparedDestinationTransaction": prepared.serialized})) + call.submit_prepared(prepared, wait=False) # polling is wait()'s job, not complete()'s + submitted = receipt.replace( + status=Status.DESTINATION_CONFIRMING, destination_tx_id=prepared.transaction_id, next_action=None, + protocol_state={**{k: v for k, v in state.items() if k != "preparedDestinationTransaction"}, + "destinationProgram": call.program_id, "destinationFunction": call.function_name}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) diff --git a/bridge-sdk/python/aleo_bridge/mcp.py b/bridge-sdk/python/aleo_bridge/mcp.py new file mode 100644 index 0000000..3afaf1e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/mcp.py @@ -0,0 +1,132 @@ +"""MCP server exposing the bridge lifecycle as tools (the ``[mcp]`` extra). + +Run: ``python -m aleo_bridge.mcp`` + +Uses the low-level ``mcp.server.Server`` (not FastMCP) so each tool advertises the exact JSON +schema from :func:`~aleo_bridge.agent.bridge_tools`. Tools run against the synchronous +:class:`~aleo_bridge.client.Bridge` in a worker thread. Writes stay behind ``confirm: true`` +exactly as in ``agent.py`` — the MCP transport adds no privileges of its own. + +Every ``mcp.*`` import here is lazy: importing this module (and ``import aleo_bridge``) never +requires the ``mcp`` package, and the first call into it that actually needs ``mcp`` raises the +SDK's own :class:`~aleo_bridge.errors.MissingExtraError` naming the real extra +(``pip install 'aleo-bridge-sdk[mcp]'``) instead of a bare ``ImportError`` from deep inside this +module. + +Environment (all read by ``Bridge.from_env`` — nothing here reads the environment directly, and +nothing here logs key material): + BRIDGE_PRIVATE_KEY Aleo signer (required) + ALEO_ENDPOINT / ALEO_NETWORK / ALEO_API_KEY / ALEO_CONSUMER_ID + ETHEREUM_RPC_URL / EVM_PRIVATE_KEY Ethereum connection (both or neither) + SOLANA_RPC_URL / SOLANA_PRIVATE_KEY Solana connection + BRIDGE_CHECKPOINT_DIR optional FileCheckpointStore directory +""" +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from .agent import bridge_tools, dispatch_tool +from .errors import MissingExtraError + +if TYPE_CHECKING: # pragma: no cover - typing only, never imported at runtime + from mcp.server import Server + from mcp.types import TextContent, Tool + +_FEATURE = "the MCP server" + + +def _mcp_types() -> tuple[Any, Any]: + """``(Tool, TextContent)``, or :class:`MissingExtraError` when the extra is not installed.""" + try: + from mcp.types import TextContent, Tool + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + return Tool, TextContent + + +def _mcp_server_cls() -> Any: + """``mcp.server.Server``, or :class:`MissingExtraError` when the extra is not installed.""" + try: + from mcp.server import Server + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + return Server + + +def tool_definitions() -> "list[Tool]": + """The agent tools as MCP ``Tool`` objects with their exact schemas.""" + Tool, _TextContent = _mcp_types() + return [Tool(name=t["name"], description=t["description"], inputSchema=t["input_schema"]) + for t in bridge_tools()] + + +async def call_tool(bridge: Any, name: str, arguments: dict[str, Any]) -> "list[TextContent]": + """Execute one tool in a worker thread; result as JSON text content. + + Runs the synchronous ``dispatch_tool`` off the event loop so a slow chain read never blocks + other in-flight MCP requests. ``dispatch_tool`` already renders every ``BridgeError`` as a + JSON-serializable dict and holds writes behind ``confirm: true`` — this function adds nothing + beyond the thread hop and the JSON encoding. + """ + _Tool, TextContent = _mcp_types() + from anyio import to_thread + + result = await to_thread.run_sync(lambda: dispatch_tool(bridge, name, arguments)) + return [TextContent(type="text", text=json.dumps(result))] + + +def build_server(bridge: Any, *, name: str = "aleo-bridge") -> "Server": + """An MCP server with every agent tool registered against *bridge*. + + No second tool list: both handlers below delegate straight to :func:`tool_definitions` / + :func:`call_tool`, which read the same table :func:`~aleo_bridge.agent.bridge_tools` and + :func:`~aleo_bridge.agent.dispatch_tool` use everywhere else. + """ + Server = _mcp_server_cls() + server: Any = Server(name) + + @server.list_tools() + async def _list_tools() -> "list[Tool]": + return tool_definitions() + + @server.call_tool() + async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> "list[TextContent]": + return await call_tool(bridge, tool_name, arguments) + + return server + + +def serve(bridge: Any) -> None: + """Serve the bridge tools over stdio until the client disconnects (blocking).""" + try: + import anyio + from mcp.server.stdio import stdio_server + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + + server = build_server(bridge) + + async def _run() -> None: + async with stdio_server() as (read, write): + await server.run(read, write, server.create_initialization_options()) + + anyio.run(_run) + + +def main() -> None: + """``python -m aleo_bridge.mcp``: bind the bridge from the environment and serve. + + ``Bridge.from_env()`` already reads ``BRIDGE_PRIVATE_KEY``, the EVM/Solana keys and their + aliases, and ``BRIDGE_CHECKPOINT_DIR`` — this function never re-reads or logs any of them. + """ + from .client import Bridge + + serve(Bridge.from_env()) + + +if __name__ == "__main__": + main() + + +__all__ = ["tool_definitions", "call_tool", "build_server", "serve", "main"] diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py new file mode 100644 index 0000000..d909714 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -0,0 +1,115 @@ +"""shield / unshield for Aleo assets with a privacy capability (port of veil actions/shield.ts, unshield.ts, +internal/aleoPrivacy.ts) plus local record selection through ``aleo.records.find``.""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +from ._calls import AleoCall +from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from .registry import Asset +from .types import PrivacyReceipt +from .units import format_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +_AMOUNT_RE = re.compile(r"\bamount:\s*(\d+)u128") + + +def record_amount(plaintext: str) -> int | None: + """The ``amount`` member of a Token record plaintext, or None when absent.""" + match = _AMOUNT_RE.search(plaintext or "") + return int(match.group(1)) if match else None + + +class PrivacyModule: + """``bridge.privacy`` — convert between public balances and private records.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + def _asset(self, asset: Any, operation: str) -> Asset: + resolved = self._bridge.registry.asset(asset) + chain = self._bridge.registry.chain(resolved.chain_id) + if chain.family != "aleo" or resolved.privacy is None: + raise UnsupportedRouteError(f'Bridge asset "{resolved.id}" does not support {operation}') + return resolved + + def _amount(self, asset: Asset, amount: Any, amount_atomic: int | None, operation: str) -> tuple[int, str]: + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=asset.decimals) + if atomic <= 0: + raise InvalidAmountError(f"{operation} amount must be greater than zero") + return atomic, f"{atomic}u128" + + def _recipient(self, asset: Asset, recipient: str | None) -> str: + value = recipient if recipient is not None else self._bridge.aleo_address() + if not asset.matches_address(value): + raise InvalidRecipientError(f"Recipient does not match the Aleo address format: {value}") + return value + + def select_record(self, program: str, amount_atomic: int, account: Any = None) -> str: + """Smallest unspent ``program``/``Token`` record covering *amount_atomic* (plaintext string).""" + rows = self._bridge.aleo.records.find(account, program=program, record="Token") + amounts: list[tuple[int, str]] = [] + for row in rows: + plaintext = row.get("record_plaintext") if isinstance(row, dict) else getattr(row, "record_plaintext", None) + value = record_amount(plaintext) if plaintext else None + if value is not None: + amounts.append((value, plaintext)) + covering = [entry for entry in amounts if entry[0] >= amount_atomic] + if not covering: + largest = max((value for value, _ in amounts), default=0) + raise InsufficientBalanceError( + f"No unspent {program} Token record covers {amount_atomic}; largest available is {largest}. " + "Shield more, join records, or lower the amount.") + return min(covering)[1] + + def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + """Public balance → private record. ARC-22 names the private recipient; ARC-20 always credits the caller.""" + resolved = self._asset(asset, "shielding") + atomic, literal = self._amount(resolved, amount, amount_atomic, "Shielding") + privacy = resolved.privacy + assert privacy is not None + if privacy.kind == "arc22": + function, inputs = "transfer_public_to_private", [self._recipient(resolved, recipient), literal] + else: + if recipient is not None and recipient != self._bridge.aleo_address(): + raise ConfigurationError("ARC-20 shield always credits the caller; omit recipient=") + function, inputs = "shield", [literal] + human = format_decimal_amount(atomic, resolved.decimals) + + def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: + return PrivacyReceipt(tx_id, resolved.id, human, atomic, "shield") + + 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).""" + 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) + 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) + 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=") + function, inputs = "unshield", [record, literal] + human = format_decimal_amount(atomic, resolved.decimals) + + def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: + return PrivacyReceipt(tx_id, resolved.id, human, atomic, "unshield") + + return self._bridge._call(privacy.program, function, inputs, build) + + +__all__ = ["PrivacyModule", "record_amount"] diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py new file mode 100644 index 0000000..40d749c --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -0,0 +1,107 @@ +"""On-disk Aleo identity for the bridge — one profile per home directory (``$ALEO_BRIDGE_HOME`` or ``~/.aleo-bridge``). + +Holds ONLY the Aleo key (mode 600). EVM and Solana keys are never written by this package; they come from +``EVM_PRIVATE_KEY`` / ``SOLANA_PRIVATE_KEY`` or caller-built connections. Shares no code with shield-swap. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .errors import ConfigurationError + +DEFAULT_ENDPOINT = "https://edge.provable.com/api" +NETWORKS = ("mainnet", "testnet") +_PROFILE = "profile.json" +_CHECKPOINTS = "checkpoints" + + +def _initial_key(network: str) -> tuple[str, str]: + """(private_key, address): imported from BRIDGE_PRIVATE_KEY / BRIDGE_PRIVATE_KEY_FILE, else freshly random.""" + import aleo + net = getattr(aleo, network) + key = (os.environ.get("BRIDGE_PRIVATE_KEY") or "").strip() + key_file = os.environ.get("BRIDGE_PRIVATE_KEY_FILE") + if not key and key_file: + key = Path(key_file).expanduser().read_text().strip() + private_key = net.PrivateKey.from_string(key) if key else net.PrivateKey.random() + return str(private_key), str(private_key.address) + + +class Profile: + """A persistent Aleo identity: created on first use, reused every session after. + + ``Profile.load_or_create()`` does both, so callers never branch on existence. *network* and + *endpoint* apply only when creating; an existing profile keeps the values it was created with. + """ + + def __init__(self, home: Path, data: dict[str, Any]) -> None: + self.home = home + self._data = data + + def __repr__(self) -> str: + return f"Profile({self.address!r}, network={self.network!r}, home={str(self.home)!r})" + + @staticmethod + def default_home() -> Path: + env = os.environ.get("ALEO_BRIDGE_HOME") + return Path(env).expanduser() if env else Path.home() / ".aleo-bridge" + + @classmethod + def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mainnet", + endpoint: str = DEFAULT_ENDPOINT) -> "Profile": + if network not in NETWORKS: + raise ConfigurationError(f"Profile network must be one of {NETWORKS}, got {network!r}") + home_path = Path(home).expanduser() if home is not None else cls.default_home() + path = home_path / _PROFILE + if path.exists(): + path.chmod(0o600) # heal a loose mode on load + profile = cls(home_path, json.loads(path.read_text())) + else: + if home_path.is_dir(): + home_path.chmod(0o700) # heal a loose mode on an existing dir + else: + home_path.mkdir(parents=True, exist_ok=True, mode=0o700) + home_path.chmod(0o700) # mkdir's mode is subject to umask; make it exact + private_key, address = _initial_key(network) + data = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + # Lost the creation race to another process — adopt the winner's key rather than + # clobbering its (already in-use) profile. + path.chmod(0o600) + data = json.loads(path.read_text()) + else: + with os.fdopen(fd, "w") as handle: + handle.write(json.dumps(data, indent=1)) + profile = cls(home_path, data) + profile.checkpoint_dir.mkdir(parents=True, exist_ok=True) + return profile + + @property + def address(self) -> str: + return self._data["address"] + + @property + def private_key(self) -> str: + """Controls the account — never log it or send it to a service.""" + return self._data["private_key"] + + @property + def network(self) -> str: + return self._data["network"] + + @property + def endpoint(self) -> str: + return self._data.get("endpoint", DEFAULT_ENDPOINT) + + @property + def checkpoint_dir(self) -> Path: + """Directory plan 4 binds as ``FileCheckpointStore``; created with the profile.""" + return self.home / _CHECKPOINTS + + +__all__ = ["DEFAULT_ENDPOINT", "Profile"] diff --git a/bridge-sdk/python/aleo_bridge/registry.py b/bridge-sdk/python/aleo_bridge/registry.py new file mode 100644 index 0000000..0d8c401 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/registry.py @@ -0,0 +1,290 @@ +"""Typed, validated views over the pinned deployment registry (``_registry_data.py``). + +Discovery is local: chains, assets and routes come from a reviewed snapshot, never from live lookups. +Assets are addressed as ``"chain/key"`` or ``(chain, key)``; symbols and chain ids compare +case-insensitively. Route ``metadata`` keeps veil's camelCase keys. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from types import ModuleType +from typing import Any, Iterable, Mapping + +from . import _registry_data +from .errors import AmbiguousRouteError, ConfigurationError, RouteNotFoundError + +AssetRef = "str | tuple[str, str] | Asset" +FAMILIES = ("aleo", "evm", "solana") +AVAILABILITIES = ("active", "metadata-required", "disabled") +PRIVACY_KINDS = ("arc20", "arc22") +_SOLANA_REQUIRED_METADATA = ( + "warpProgramAddress", "tokenPda", "nativeCollateralPda", "dispatchAuthorityPda", "mailboxProgramAddress", + "mailboxOutboxPda", "igpProgramAddress", "igpProgramDataPda", "igpAccount", "splNoopProgramAddress", + "destinationDomain", "destinationGasAmount", "registryCommit", "solanaReviewedAt", "solanaConfigSource", +) + + +@dataclass(frozen=True) +class Chain: + id: str + display_name: str + family: str # "aleo" | "evm" | "solana" + environment: str # "mainnet" | "testnet" + native_symbol: str + protocol_domains: Mapping[str, int] = field(default_factory=dict) # {"xreserve": 0, "hyperlane": 1} + + +@dataclass(frozen=True) +class Locator: + kind: str # "aleo-program" | "evm-contract" | "solana-mint" | "native" + value: str + token_id: str | None = None + + +@dataclass(frozen=True) +class Privacy: + kind: str # "arc20" | "arc22" + program: str + + +@dataclass(frozen=True) +class Asset: + id: str # "chain/key" + key: str + chain_id: str + symbol: str + name: str + decimals: int + kind: str # "native" | "token" + locator: Locator | None = None + address_regex: str | None = None + privacy: Privacy | None = None + + def matches_address(self, value: str) -> bool: + """Whether *value* matches this asset's chain address format (False when no regex is declared).""" + return bool(self.address_regex) and isinstance(value, str) and re.fullmatch(self.address_regex, value) is not None + + +@dataclass(frozen=True) +class Route: + id: str # "protocol:source->destination" + protocol: str # "xreserve" | "hyperlane" + environment: str + source_asset_id: str + destination_asset_id: str + availability: str # "active" | "metadata-required" | "disabled" + deployment_id: str | None = None + source: str | None = None + metadata: Mapping[str, "str | int | bool"] = field(default_factory=dict) + + @property + def active(self) -> bool: + return self.availability == "active" + + def meta_str(self, key: str) -> str: + value = self.metadata.get(key) + if not isinstance(value, str) or not value: + raise ConfigurationError(f"Route metadata {key} is missing: {self.id}") + return value + + def meta_int(self, key: str, default: int | None = None) -> int: + value = self.metadata.get(key, default) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ConfigurationError(f"Route metadata {key} is invalid: {self.id}") + return value + + +def _parse_asset_ref(ref: Any) -> tuple[str, str]: + if isinstance(ref, tuple) and len(ref) == 2: + return str(ref[0]).lower(), str(ref[1]).lower() + if isinstance(ref, str) and ref.count("/") == 1: + chain, key = ref.split("/") + return chain.lower(), key.lower() + raise RouteNotFoundError(f"Asset reference must be 'chain/key' or (chain, key), got {ref!r}") + + +class Registry: + """Immutable snapshot with case-insensitive lookups. Build with :func:`build_registry` or directly.""" + + def __init__(self, version: str, chains: Iterable[Chain], assets: Iterable[Asset], routes: Iterable[Route]) -> None: + self.version = version + self._chains = tuple(chains) + self._assets = tuple(assets) + self._routes = tuple(routes) + self._chain_by_id = {c.id: c for c in self._chains} + self._asset_by_id = {a.id: a for a in self._assets} + self._route_by_id = {r.id: r for r in self._routes} + + def __repr__(self) -> str: + return f"Registry(version={self.version!r}, chains={len(self._chains)}, assets={len(self._assets)}, routes={len(self._routes)})" + + # ── chains ── + def chains(self, environment: str | None = None) -> list[Chain]: + return [c for c in self._chains if environment is None or c.environment == environment] + + def chain(self, chain_id: str) -> Chain: + for c in self._chains: + if c.id.lower() == str(chain_id).lower(): + return c + raise RouteNotFoundError(f"Unknown bridge chain: {chain_id}") + + # ── assets ── + def assets(self, chain: str | None = None, symbol: str | None = None, environment: str | None = None) -> list[Asset]: + out = [] + for a in self._assets: + if chain is not None and a.chain_id.lower() != chain.lower(): + continue + if symbol is not None and a.symbol.lower() != symbol.lower(): + continue + if environment is not None and self._chain_by_id[a.chain_id].environment != environment: + continue + out.append(a) + return out + + def asset(self, ref: Any) -> Asset: + if isinstance(ref, Asset): + return ref + chain, key = _parse_asset_ref(ref) + for a in self._assets: + if a.chain_id.lower() == chain and a.key.lower() == key: + return a + raise RouteNotFoundError(f"Unknown bridge asset: {chain}/{key}") + + # ── routes ── + def _endpoint_matches(self, asset_id: str, selector: str | None) -> bool: + if selector is None: + return True + asset = self._asset_by_id[asset_id] + if "/" in selector: + return asset.id.lower() == selector.lower() + return asset.chain_id.lower() == selector.lower() + + def routes(self, source: str | None = None, destination: str | None = None, protocol: str | None = None, + symbol: str | None = None, include_unavailable: bool = False, environment: str | None = None) -> list[Route]: + """Filter routes; *source*/*destination* accept a chain id or an ``"chain/key"`` asset ref. + Disabled routes are hidden unless *include_unavailable*; metadata-required routes are always listed.""" + out = [] + for r in self._routes: + if not include_unavailable and r.availability == "disabled": + continue + if environment is not None and r.environment != environment: + continue + if protocol is not None and r.protocol != protocol: + continue + if not self._endpoint_matches(r.source_asset_id, source) or not self._endpoint_matches(r.destination_asset_id, destination): + continue + if symbol is not None: + symbols = {self._asset_by_id[r.source_asset_id].symbol.lower(), self._asset_by_id[r.destination_asset_id].symbol.lower()} + if symbol.lower() not in symbols: + continue + out.append(r) + return out + + def route(self, route_id: str) -> Route: + try: + return self._route_by_id[route_id] + except KeyError: + raise RouteNotFoundError(f"Unknown bridge route: {route_id}") from None + + def find_route(self, source: Any, destination: Any, protocol: str | None = None) -> Route: + """prepare()'s lookup: the single non-disabled route for an exact asset pair (metadata-required included).""" + src, dst = self.asset(source), self.asset(destination) + matches = [r for r in self._routes + if r.source_asset_id == src.id and r.destination_asset_id == dst.id + and r.availability != "disabled" and (protocol is None or r.protocol == protocol)] + if not matches: + raise RouteNotFoundError( + f"No bridge route from {src.id} to {dst.id}" + (f" over {protocol}" if protocol else "") + + "; list candidates with registry.routes(source=..., destination=...)") + if len(matches) > 1: + raise AmbiguousRouteError( + f"{len(matches)} routes from {src.id} to {dst.id}: {[r.id for r in matches]} — pass protocol=") + return matches[0] + + +def validate_registry(registry: Registry) -> Registry: + """Port of veil ``validateBridgeRegistry`` (+ the Solana metadata gate). Returns *registry* unchanged.""" + if not registry.version.strip(): + raise ConfigurationError("Bridge registry version must not be empty") + chain_ids: set[str] = set() + for chain in registry._chains: + if chain.id in chain_ids: + raise ConfigurationError(f"Duplicate bridge chain id: {chain.id}") + if chain.family not in FAMILIES: + raise ConfigurationError(f"Bridge chain {chain.id} has unsupported family {chain.family!r}") + chain_ids.add(chain.id) + asset_ids: set[str] = set() + asset_keys: set[str] = set() + for asset in registry._assets: + if asset.id in asset_ids: + raise ConfigurationError(f"Duplicate bridge asset id: {asset.id}") + if asset.chain_id not in chain_ids: + raise ConfigurationError(f"Bridge asset {asset.id} references unknown chain {asset.chain_id}") + if not asset.key.strip(): + raise ConfigurationError(f"Bridge asset {asset.id} has an empty key") + scoped = f"{asset.chain_id}/{asset.key}" + if scoped in asset_keys: + raise ConfigurationError(f"Duplicate bridge asset key: {scoped}") + if isinstance(asset.decimals, bool) or not isinstance(asset.decimals, int) or asset.decimals < 0: + raise ConfigurationError(f"Bridge asset {asset.id} has invalid decimals {asset.decimals}") + if asset.address_regex: + try: + re.compile(asset.address_regex) + except re.error as exc: + raise ConfigurationError(f"Bridge asset {asset.id} has an invalid address validation regex") from exc + if asset.privacy is not None: + if registry._chain_by_id[asset.chain_id].family != "aleo": + raise ConfigurationError(f"Bridge asset {asset.id} declares a privacy capability on a non-Aleo chain") + if not asset.privacy.program.strip(): + raise ConfigurationError(f"Bridge asset {asset.id} has an empty privacy program") + if asset.privacy.kind not in PRIVACY_KINDS: + raise ConfigurationError(f"Bridge asset {asset.id} has an unsupported privacy capability kind") + asset_ids.add(asset.id) + asset_keys.add(scoped) + route_ids: set[str] = set() + for route in registry._routes: + if route.id in route_ids: + raise ConfigurationError(f"Duplicate bridge route id: {route.id}") + if route.source_asset_id not in asset_ids: + raise ConfigurationError(f"Bridge route {route.id} references unknown source asset {route.source_asset_id}") + if route.destination_asset_id not in asset_ids: + raise ConfigurationError(f"Bridge route {route.id} references unknown destination asset {route.destination_asset_id}") + if route.availability not in AVAILABILITIES: + raise ConfigurationError(f"Bridge route {route.id} has unsupported availability {route.availability!r}") + source_chain = registry._chain_by_id[registry._asset_by_id[route.source_asset_id].chain_id] + destination_chain = registry._chain_by_id[registry._asset_by_id[route.destination_asset_id].chain_id] + if source_chain.environment != route.environment or destination_chain.environment != route.environment: + raise ConfigurationError(f"Bridge route {route.id} crosses registry environments") + if route.protocol == "hyperlane" and route.availability == "active" and source_chain.family == "solana": + for key in _SOLANA_REQUIRED_METADATA: + value = route.metadata.get(key) + ok = isinstance(value, int) and not isinstance(value, bool) if key == "destinationDomain" \ + else isinstance(value, str) and bool(value) + if not ok: + raise ConfigurationError(f"Bridge route {route.id} is active but missing required Solana Hyperlane metadata") + route_ids.add(route.id) + return registry + + +def build_registry(data: ModuleType = _registry_data) -> Registry: + """Turn the plain-dict literals of a data module into a validated :class:`Registry`.""" + chains = [Chain(c["id"], c["displayName"], c["family"], c["environment"], c["nativeCurrencySymbol"], + dict(c.get("protocolDomains", {}))) for c in data.CHAINS] + assets = [] + for a in data.ASSETS: + loc = a.get("locator") + priv = a.get("privacy") + assets.append(Asset(a["id"], a["key"], a["chainId"], a["symbol"], a["name"], a["decimals"], a["kind"], + Locator(loc["kind"], loc["value"], loc.get("tokenId")) if loc else None, + a.get("addressValidationRegex"), + Privacy(priv["kind"], priv["program"]) if priv else None)) + routes = [Route(r["id"], r["protocol"], r["environment"], r["sourceAssetId"], r["destinationAssetId"], + r["availability"], r.get("deploymentId"), r.get("source"), dict(r.get("metadata", {}))) + for r in data.ROUTES] + return validate_registry(Registry(data.REGISTRY_VERSION, chains, assets, routes)) + + +DEFAULT_REGISTRY: Registry = build_registry() + +__all__ = ["Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "build_registry", "validate_registry"] diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py new file mode 100644 index 0000000..eb4717b --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -0,0 +1,980 @@ +"""Solana transport, connection and the ``bridge.sol`` module (SOL → Aleo over Hyperlane). + +solders is imported lazily through :func:`_libs`; an install without the ``solana`` extra +raises :class:`MissingExtraError` at the point of use, never at import. Layouts live in +:mod:`aleo_bridge._sealevel` (pure); this module adds a synchronous JSON-RPC transport +(solana-py ≥ 0.36 is async-only), signing, broadcast and status polling. +""" +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +import os +import threading +import time +from dataclasses import dataclass, replace +from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable + +import requests + +from . import _sealevel as sl +from ._calls import SolCall +from ._plan import build_plan +from .encoding import aleo_address_to_bytes32 +from .errors import ( + BridgeError, + CheckpointInvalidError, + ConfigurationError, + InsufficientBalanceError, + InvalidAmountError, + InvalidRecipientError, + MissingExtraError, + RegistryVersionMismatchError, + RouteNotFoundError, + UnsupportedRouteError, +) +from .registry import Route +from .types import DispatchReceipt, Fee, Plan, Receipt, SolanaHyperlaneQuote, Status, Step +from .units import format_decimal_amount, resolve_amount + +DEFAULT_SOLANA_RPC_URL = "https://api.mainnet-beta.solana.com" +CONFIRMED = "confirmed" +COMMITMENTS = ("processed", "confirmed", "finalized") +SOLANA_CHAIN_ID = "solana" +SOLANA_SOL_ASSET_ID = "solana/sol" +ALEO_SOL_ASSET_ID = "aleo/sol" + + +@dataclass(frozen=True) +class _SolanaLibs: + Keypair: Any + Pubkey: Any + Signature: Any + Hash: Any + Instruction: Any + AccountMeta: Any + MessageV0: Any + to_bytes_versioned: Any + VersionedTransaction: Any + set_compute_unit_limit: Any + + +_LIBS: _SolanaLibs | None = None + + +def _libs() -> _SolanaLibs: + """Import solders once; translate a missing extra into MissingExtraError.""" + global _LIBS + if _LIBS is None: + try: + from solders.compute_budget import set_compute_unit_limit + from solders.hash import Hash + from solders.instruction import AccountMeta, Instruction + from solders.keypair import Keypair + from solders.message import MessageV0, to_bytes_versioned + from solders.pubkey import Pubkey + from solders.signature import Signature + from solders.transaction import VersionedTransaction + except ImportError as exc: + raise MissingExtraError("solana", "Solana connections and SOL transfers") from exc + _LIBS = _SolanaLibs(Keypair, Pubkey, Signature, Hash, Instruction, AccountMeta, MessageV0, + to_bytes_versioned, VersionedTransaction, set_compute_unit_limit) + return _LIBS + + +# --- synchronous JSON-RPC transport (veil src/solana/rpc.ts) ------------------------------------ + +@dataclass(frozen=True) +class SendOptions: + """Broadcast options; attribute-compatible with solana-py's ``TxOpts``.""" + skip_preflight: bool = False + preflight_commitment: str = CONFIRMED + skip_confirmation: bool = True + + +@dataclass(frozen=True) +class RpcResult: + value: Any + + +@dataclass(frozen=True) +class LatestBlockhash: + blockhash: Any # solders Hash + last_valid_block_height: int + + +@dataclass(frozen=True) +class AccountInfo: + data: bytes + lamports: int + owner: str + + +@dataclass(frozen=True) +class SignatureStatus: + err: Any + confirmation_status: str | None + + +@dataclass(frozen=True) +class TransactionMeta: + log_messages: list[str] | None + + +@dataclass(frozen=True) +class TransactionWithMeta: + meta: TransactionMeta | None + + +@dataclass(frozen=True) +class ConfirmedTransaction: + transaction: TransactionWithMeta + slot: int + + +class SolanaRpcClient: + """Minimal synchronous Solana JSON-RPC client exposing the solana-py method surface SolModule uses. + + Every method issues one POST, validates the envelope (HTTP status, JSON, JSON-RPC ``error``, + ``result`` presence) and the result shape, and returns an object with ``.value`` shaped like + solana-py's response types. No method signs or retries. + """ + + def __init__(self, url: str, *, commitment: str = CONFIRMED, session: Any = None, timeout: float = 30.0) -> None: + if commitment not in COMMITMENTS: + raise ConfigurationError(f"Solana commitment must be one of {COMMITMENTS}, got {commitment!r}") + self.url = url + self.commitment = commitment + self.timeout = timeout + self._session = session or requests.Session() + + def _commitment(self, commitment: str | None) -> dict[str, str]: + return {"commitment": str(commitment or self.commitment)} + + def _call(self, method: str, params: list[Any]) -> Any: + try: + response = self._session.post(self.url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=self.timeout, headers={"content-type": "application/json", "cache-control": "no-cache"}) + except requests.RequestException as exc: + raise BridgeError(f"Solana RPC {method} request failed: {exc}") from exc + if not 200 <= response.status_code < 300: + raise BridgeError(f"Solana RPC {method} request failed with HTTP status {response.status_code}") + try: + body = response.json() + except ValueError as exc: + raise BridgeError(f"Solana RPC {method} returned invalid JSON") from exc + if not isinstance(body, dict): + raise BridgeError(f"Solana RPC {method} returned an invalid JSON-RPC response") + error = body.get("error") + if error: + details = f"; {json.dumps(error['data'])}" if isinstance(error, dict) and "data" in error else "" + message = error.get("message", "unknown error") if isinstance(error, dict) else str(error) + raise BridgeError(f"Solana RPC {method} returned a JSON-RPC error: {message}{details}") + if "result" not in body: + raise BridgeError(f"Solana RPC {method} returned an invalid result envelope") + return body["result"] + + @staticmethod + def _integer(method: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise BridgeError(f"Solana RPC {method} returned an invalid result") + return value + + @staticmethod + def _contextual(method: str, result: Any) -> Any: + if not isinstance(result, dict) or "value" not in result: + raise BridgeError(f"Solana RPC {method} returned an invalid contextual result") + return result["value"] + + def get_latest_blockhash(self, commitment: str | None = None) -> RpcResult: + value = self._contextual("getLatestBlockhash", self._call("getLatestBlockhash", [self._commitment(commitment)])) + if not isinstance(value, dict) or not isinstance(value.get("blockhash"), str) or not value["blockhash"]: + raise BridgeError("Solana RPC getLatestBlockhash returned an invalid result") + return RpcResult(LatestBlockhash(_libs().Hash.from_string(value["blockhash"]), + self._integer("getLatestBlockhash", value.get("lastValidBlockHeight")))) + + def get_block_height(self, commitment: str | None = None) -> RpcResult: + return RpcResult(self._integer("getBlockHeight", self._call("getBlockHeight", [self._commitment(commitment)]))) + + def is_blockhash_valid(self, blockhash: Any, commitment: str | None = None) -> RpcResult: + value = self._contextual("isBlockhashValid", self._call("isBlockhashValid", [str(blockhash), self._commitment(commitment)])) + if not isinstance(value, bool): + raise BridgeError("Solana RPC isBlockhashValid returned an invalid result") + return RpcResult(value) + + def get_balance(self, pubkey: Any, commitment: str | None = None) -> RpcResult: + value = self._contextual("getBalance", self._call("getBalance", [str(pubkey), self._commitment(commitment)])) + return RpcResult(self._integer("getBalance", value)) + + def get_account_info(self, pubkey: Any, commitment: str | None = None, encoding: str = "base64") -> RpcResult: + if encoding != "base64": + raise BridgeError("SolanaRpcClient.get_account_info supports base64 encoding only") + value = self._contextual("getAccountInfo", self._call("getAccountInfo", [str(pubkey), {"encoding": "base64", **self._commitment(commitment)}])) + if value is None: + return RpcResult(None) + data = value.get("data") if isinstance(value, dict) else None + if not isinstance(data, list) or len(data) != 2 or not isinstance(data[0], str) or data[1] != "base64": + raise BridgeError("Solana RPC getAccountInfo returned invalid base64 account data") + try: + raw = base64.b64decode(data[0], validate=True) + except ValueError as exc: + raise BridgeError("Solana RPC getAccountInfo returned invalid base64 account data") from exc + return RpcResult(AccountInfo(raw, int(value.get("lamports", 0)), str(value.get("owner", "")))) + + def get_fee_for_message(self, message: Any, commitment: str | None = None) -> RpcResult: + raw = bytes(message) if isinstance(message, (bytes, bytearray)) else _libs().to_bytes_versioned(message) + value = self._contextual("getFeeForMessage", self._call("getFeeForMessage", [base64.b64encode(raw).decode(), self._commitment(commitment)])) + return RpcResult(None if value is None else self._integer("getFeeForMessage", value)) + + def get_minimum_balance_for_rent_exemption(self, usize: int, commitment: str | None = None) -> RpcResult: + if isinstance(usize, bool) or not isinstance(usize, int) or usize < 0: + raise BridgeError("Solana rent data length must be a non-negative integer") + return RpcResult(self._integer("getMinimumBalanceForRentExemption", + self._call("getMinimumBalanceForRentExemption", [usize, self._commitment(commitment)]))) + + def send_raw_transaction(self, txn: bytes, opts: Any = None) -> RpcResult: + opts = opts or SendOptions() + config = {"encoding": "base64", "skipPreflight": bool(opts.skip_preflight), "preflightCommitment": str(opts.preflight_commitment)} + result = self._call("sendTransaction", [base64.b64encode(bytes(txn)).decode(), config]) + if not isinstance(result, str) or not result: + raise BridgeError("Solana RPC sendTransaction returned an invalid signature") + return RpcResult(_libs().Signature.from_string(result)) + + def close(self) -> None: + """Release the HTTP session (and its connection pool). Idempotent; ``requests`` allows reuse + afterwards, so a closed client that is used again simply opens fresh connections.""" + closer = getattr(self._session, "close", None) + if callable(closer): + closer() + + def get_signature_statuses(self, signatures: Sequence[Any], search_transaction_history: bool = False) -> RpcResult: + value = self._contextual("getSignatureStatuses", self._call( + "getSignatureStatuses", [[str(s) for s in signatures], {"searchTransactionHistory": bool(search_transaction_history)}])) + if not isinstance(value, list) or len(value) != len(signatures): + raise BridgeError("Solana RPC getSignatureStatuses returned an invalid result") + statuses: list[SignatureStatus | None] = [] + for status in value: + if status is None: + statuses.append(None) + continue + if not isinstance(status, dict) or "err" not in status: + raise BridgeError("Solana RPC getSignatureStatuses returned an invalid status") + confirmation = status.get("confirmationStatus") + if confirmation is not None and confirmation not in COMMITMENTS: + raise BridgeError(f"Solana RPC getSignatureStatuses returned unsupported confirmation status: {confirmation}") + statuses.append(SignatureStatus(status["err"], confirmation)) + return RpcResult(statuses) + + def get_transaction(self, tx_sig: Any, encoding: str = "json", commitment: str | None = None, + max_supported_transaction_version: int | None = None) -> RpcResult: + config: dict[str, Any] = {"encoding": encoding, **self._commitment(commitment)} + if max_supported_transaction_version is not None: + config["maxSupportedTransactionVersion"] = max_supported_transaction_version + result = self._call("getTransaction", [str(tx_sig), config]) + if result is None: + return RpcResult(None) + if not isinstance(result, dict) or "meta" not in result: + raise BridgeError("Solana RPC getTransaction returned an invalid result") + meta = result["meta"] + if meta is None: + return RpcResult(ConfirmedTransaction(TransactionWithMeta(None), int(result.get("slot", 0)))) + if not isinstance(meta, dict) or "logMessages" not in meta: + raise BridgeError("Solana RPC getTransaction returned invalid metadata") + logs = meta["logMessages"] + if logs is not None and (not isinstance(logs, list) or not all(isinstance(line, str) for line in logs)): + raise BridgeError("Solana RPC getTransaction returned invalid logs") + return RpcResult(ConfirmedTransaction(TransactionWithMeta(TransactionMeta(logs)), int(result.get("slot", 0)))) + + +class _AsyncClientAdapter: + """Drives a solana-py ``AsyncClient`` (0.36+ is async-only) synchronously on a private event-loop + thread, and supplies ``is_blockhash_valid`` (missing from solana-py) and ``send_raw_transaction`` + with our ``SendOptions`` translated to ``TxOpts``. Other methods are forwarded unchanged.""" + + def __init__(self, client: Any) -> None: + self._client = client + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._loop.run_forever, name="aleo-bridge-solana-rpc", daemon=True) + self._thread.start() + self._closed = False + + def _run(self, coroutine: Any, timeout: float | None = None) -> Any: + return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result(timeout) + + def close(self, timeout: float = 5.0) -> None: + """Close the wrapped client, then stop the private loop thread. Idempotent. + + The wrapped client's own ``close()`` runs FIRST and on our loop: solana-py's ``AsyncClient`` + owns an aiohttp session that can only be closed from the loop it was created on, so stopping + the thread first would leak the connection pool. It is bounded by *timeout* and its failure is + swallowed — a third-party transport that cannot close must not leave our loop thread running + forever — and the loop is stopped either way. ``_closed`` is set only once that stop has been + issued, so a raising ``close()`` cannot turn the next call into a no-op over a live thread. + The loop itself is closed only after the thread has actually exited; closing a running loop raises. + """ + if self._closed: + return + try: + closer = getattr(self._client, "close", None) + if callable(closer): + result = closer() + if inspect.isawaitable(result): + self._run(result, timeout=timeout) + except Exception: # noqa: BLE001 — including a _run timeout + pass # best-effort release; the thread still has to stop + finally: + self._loop.call_soon_threadsafe(self._loop.stop) + self._closed = True + self._thread.join(timeout=timeout) + if self._thread.is_alive(): + return # still running: leave the loop alone rather than raise + if not self._loop.is_closed(): + self._loop.close() + + def __getattr__(self, name: str) -> Any: + attribute = getattr(self._client, name) + if inspect.iscoroutinefunction(attribute): + return lambda *args, **kwargs: self._run(attribute(*args, **kwargs)) + return attribute + + def is_blockhash_valid(self, blockhash: Any, commitment: str | None = None) -> Any: + from solders.commitment_config import CommitmentLevel + from solders.rpc.config import RpcContextConfig + from solders.rpc.requests import IsBlockhashValid + from solders.rpc.responses import IsBlockhashValidResp + + level = {"processed": CommitmentLevel.Processed, "confirmed": CommitmentLevel.Confirmed, + "finalized": CommitmentLevel.Finalized}[commitment or CONFIRMED] + request = IsBlockhashValid(blockhash, RpcContextConfig(commitment=level)) + return self._run(self._client._provider.make_request(request, IsBlockhashValidResp)) + + def send_raw_transaction(self, txn: bytes, opts: Any = None) -> Any: + try: + from solana.rpc.models import TxOpts + except ImportError: # solana-py < 0.36 kept TxOpts in solana.rpc.types + try: + from solana.rpc.types import TxOpts + except ImportError as exc: + raise MissingExtraError("solana", "solana-py AsyncClient transport") from exc + opts = opts or SendOptions() + tx_opts = TxOpts(skip_confirmation=True, skip_preflight=bool(opts.skip_preflight), preflight_commitment=str(opts.preflight_commitment)) + return self._run(self._client.send_raw_transaction(bytes(txn), tx_opts)) + + +@runtime_checkable +class SolanaSigner(Protocol): + """solana-py's signer shape: a solders ``Keypair`` or any wallet exposing these two methods.""" + + def pubkey(self) -> Any: ... + + def sign_message(self, message: bytes) -> Any: ... + + +def keypair_from_private_key(private_key: str | bytes) -> Any: + """Parse a Solana secret: base58 (Phantom export), a JSON array of 64 ints (solana-cli ``id.json``), + 64 raw bytes (seed ‖ pubkey) or a 32-byte seed.""" + libs = _libs() + if isinstance(private_key, (bytes, bytearray, memoryview)): + raw = bytes(private_key) + else: + # Every failure below raises OUTSIDE its except block, so neither __cause__ nor __context__ is + # set: a chained JSONDecodeError carries the whole document in .doc, and a solders parse error + # may echo its input — either one would print the private key with the traceback. + text = private_key.strip() + if text.startswith("["): + values: Any = None + malformed = False + try: + values = json.loads(text) + except ValueError: + malformed = True + if malformed: + raise ConfigurationError( + "Solana private key JSON array is malformed; expected the 64 integers of a solana-cli id.json") + if not isinstance(values, list) or not all(isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255 for v in values): + raise ConfigurationError("Solana private key JSON array must hold integers 0–255") + raw = bytes(values) + else: + keypair = None + try: + keypair = libs.Keypair.from_base58_string(text) + except Exception: # noqa: BLE001 — solders raises its own parse error types + keypair = None + if keypair is None: + raise ConfigurationError("Solana private key is not a valid base58 64-byte secret") + return keypair + if len(raw) == 64: + return libs.Keypair.from_bytes(raw) + if len(raw) == 32: + return libs.Keypair.from_seed(raw) + raise ConfigurationError(f"Solana private key must be 64 bytes (seed || pubkey) or a 32-byte seed, got {len(raw)}") + + +class Solana: + """Solana transport plus an optional signer (spec §3.2). + + ``Solana(rpc_url)`` builds ``SolanaRpcClient(rpc_url, commitment="confirmed")``; + ``Solana(client=…)`` reuses a caller-configured client: anything with the solana-py read/send + method surface (its own commitment, timeout, headers), or a solana-py ``AsyncClient``, which is + driven synchronously through :class:`_AsyncClientAdapter`. Exactly one of ``rpc_url``/``client`` + may be given; at most one of ``signer``/``private_key``. A connection without a signer is + read-only (quotes and status reads work, ``send`` does not). + """ + + def __init__(self, rpc_url: str | None = None, *, client: Any = None, signer: Any = None, + private_key: str | bytes | None = None) -> None: + if rpc_url is not None and client is not None: + raise ConfigurationError("Solana(): pass rpc_url or client, not both") + if signer is not None and private_key is not None: + raise ConfigurationError("Solana(): pass signer or private_key, not both") + if client is None: + _libs() # SolanaRpcClient returns solders Hash/Signature values + rpc_url = rpc_url or DEFAULT_SOLANA_RPC_URL + client = SolanaRpcClient(rpc_url, commitment=CONFIRMED) + elif inspect.iscoroutinefunction(getattr(client, "get_balance", None)): + client = _AsyncClientAdapter(client) # solana-py ≥ 0.36 AsyncClient + self._rpc_url = rpc_url + self._client = client + if private_key is not None: + signer = keypair_from_private_key(private_key) + if signer is not None and not (callable(getattr(signer, "pubkey", None)) and callable(getattr(signer, "sign_message", None))): + raise ConfigurationError("Solana signer must expose pubkey() and sign_message(bytes) — a solders Keypair or a solana-py Signer") + self._signer = signer + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "Solana | None": + """Private key from ``SOLANA_PRIVATE_KEY`` else ``BRIDGE_SOLANA_PRIVATE_KEY`` (the user's shell + exports the latter); RPC from ``SOLANA_RPC_URL`` else ``BRIDGE_LIVE_SOLANA_RPC_URL`` else the + default. A key (either name) → signing connection; URL alone → read-only; neither → ``None``.""" + env = os.environ if env is None else env + key = env.get("SOLANA_PRIVATE_KEY") or env.get("BRIDGE_SOLANA_PRIVATE_KEY") + url = env.get("SOLANA_RPC_URL") or env.get("BRIDGE_LIVE_SOLANA_RPC_URL") or None + if key: + return cls(url, private_key=key) + if url: + return cls(url) + return None + + @property + def client(self) -> Any: + return self._client + + @property + def signer(self) -> Any: + return self._signer + + @property + def rpc_url(self) -> str | None: + return self._rpc_url + + @property + def can_sign(self) -> bool: + return self._signer is not None + + @property + def pubkey(self) -> Any: + if self._signer is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") + return self._signer.pubkey() + + @property + def address(self) -> str | None: + return None if self._signer is None else str(self._signer.pubkey()) + + def sign_message(self, message: bytes) -> Any: + """Fee-payer signature over compiled message bytes (``to_bytes_versioned`` for v0 messages).""" + if self._signer is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") + return self._signer.sign_message(bytes(message)) + + def close(self) -> None: + """Release the wrapped client's resources (idempotent). + + The default transport closes its HTTP session; an adapted async solana-py client closes the + client itself and then its private event-loop thread. A client with no ``close()`` is a no-op. + ``__exit__`` swallows whatever this raises; call it directly to see the error.""" + closer = getattr(self._client, "close", None) + if callable(closer): + closer() + + def __enter__(self) -> "Solana": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + try: + self.close() + except Exception: # noqa: BLE001 — releasing a transport must never + pass # replace (or invent) the caller's own exception + + +def _confirmation_name(status: Any) -> str | None: + """Normalise solders' TransactionConfirmationStatus enum (or a plain string) to 'processed'|'confirmed'|'finalized'.""" + value = getattr(status, "confirmation_status", None) + if value is None: + return None + name = getattr(value, "name", None) or str(value) + return str(name).rsplit(".", 1)[-1].lower() + + +def _assert_amount_matches_plan(plan: Plan, *, amount: str | None, amount_atomic: int | None, decimals: int) -> None: + """A plan pins the amount. Re-stating the same one is harmless; a different one is a mistake the + caller must see, not a silent override of the plan they prepared (``EthModule``'s rule).""" + if amount is None and amount_atomic is None: + return + given = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if given != plan.amount_atomic: + raise ValueError(f"plan is for {plan.amount_atomic} atomic units, but {given} was passed; " + "pass plan= alone or re-run quote() for the amount you want") + + +@dataclass +class SolBuild: + """Everything ``send`` needs after ``build``: the quote, the compiled message, the partially signed + transaction, the unique-message address that seeds the PDAs, and the blockhash lifetime.""" + quote: SolanaHyperlaneQuote + message: Any + transaction: Any + unique_message_address: str + blockhash: str + last_valid_block_height: int + sender: str + destination_domain: int + + +def _signature_status(client: Any, signature: Any) -> str | None: + """'failed' | 'processed' | 'confirmed' | 'finalized' | None (unknown); raises on RPC errors. + + ``None`` means one thing only: the node has NO row for this signature. A row that exists but + carries no ``confirmationStatus`` (and no ``err``) is a transaction the node has seen — processed + at least — so it reports ``'processed'``. Collapsing that into ``None`` would send both callers to + the blockhash probe and let a landed transfer be reported EXPIRED, inviting a resend. + """ + value = client.get_signature_statuses([signature], search_transaction_history=True).value + status = value[0] if value else None + if status is None: + return None + if getattr(status, "err", None) is not None: + return "failed" + name = _confirmation_name(status) + if name is None: + return "processed" + if name not in ("processed", "confirmed", "finalized"): + raise BridgeError(f"Solana RPC getSignatureStatuses returned unsupported confirmation status: {name}") + return name + + +def _poll_for_confirmation(client: Any, signature: str, blockhash: str, timeout_seconds: float, + poll_seconds: float) -> str | None: + """Poll until confirmed/finalized ('confirmed'|'finalized'), the blockhash expires ('expired'), or the + deadline passes (None). A status read that raises is swallowed — the transaction is already broadcast, + so a transient RPC error must not be reported as a transfer failure. An on-chain ``err`` raises. + + The blockhash probe runs only while the signature has NO status at all: a ``processed`` transaction has + already landed, and reporting it 'expired' would invite a resend (the same rule ``source_status`` applies). + ``processed`` therefore keeps polling until it confirms or the deadline passes.""" + libs = _libs() + sig = libs.Signature.from_string(signature) + hash_ = libs.Hash.from_string(blockhash) + interval = max(float(poll_seconds), 0.1) + deadline = time.monotonic() + max(float(timeout_seconds), 0.0) + while True: + try: + status = _signature_status(client, sig) + except Exception: # noqa: BLE001 — transport/decoding errors are transient here + status = None + if status == "failed": + raise BridgeError(f"Solana Hyperlane transfer failed on-chain: {signature}") + if status in ("confirmed", "finalized"): + return status + if status is None: # no status at all: only then can the blockhash have expired + try: + if not client.is_blockhash_valid(hash_, commitment=CONFIRMED).value: + return "expired" + except Exception: # noqa: BLE001 + pass # advisory while the signature may still land + if time.monotonic() >= deadline: + return None + time.sleep(interval) + + +class SolModule: + """``bridge.sol`` — Solana-origin SOL → Aleo over the Hyperlane warp route (spec §6). + + Reads (``balance``, ``quote_transfer_remote``, ``source_status``) work on a read-only + connection; ``transfer_remote(...).send()`` needs a signer. Route metadata is re-validated + from the live registry on every call. + """ + + def __init__(self, bridge: Any, conn: Solana) -> None: + self._bridge = bridge + self.conn = conn + + @property + def client(self) -> Any: + return self.conn.client + + @property + def registry(self) -> Any: + return self._bridge.registry + + @property + def environment(self) -> str: + return self._bridge.environment + + def outbound_route(self) -> Route: + """The environment's SOL → Aleo Hyperlane route (RouteNotFoundError on testnet, which has none). + + ``SOLANA_SOL_ASSET_ID``/``ALEO_SOL_ASSET_ID`` are fixed mainnet asset ids (there is no + testnet Solana chain in the registry), so ``find_route`` alone would resolve the mainnet + route regardless of ``self.environment``; this guards that the route's own environment + matches the module's, mirroring how ``EthModule`` scopes its route lookups by environment. + """ + route = self.registry.find_route(SOLANA_SOL_ASSET_ID, ALEO_SOL_ASSET_ID, protocol="hyperlane") + if route.environment != self.environment: + raise RouteNotFoundError(f"No Solana Hyperlane route to Aleo for environment {self.environment!r}") + return route + + def metadata(self, route: Route | None = None) -> sl.SolanaRouteMetadata: + return sl.solana_route_metadata(route or self.outbound_route()) + + # --- reads ------------------------------------------------------------------------------ + + def _pubkey(self, address: str) -> Any: + return _libs().Pubkey.from_string(address) + + def _account_data(self, address: str) -> bytes | None: + value = self.client.get_account_info(self._pubkey(address), commitment=CONFIRMED, encoding="base64").value + if value is None: + return None + data = value.data + if isinstance(data, (list, tuple)): # raw JSON shape: ["", "base64"] + return base64.b64decode(data[0]) + return bytes(data) + + def _balance_of(self, address: str) -> int: + return int(self.client.get_balance(self._pubkey(address), commitment=CONFIRMED).value) + + def balance(self) -> int: + """Lamports held by the connected wallet.""" + address = self.conn.address + if address is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to read the wallet balance") + return self._balance_of(address) + + # --- quote ------------------------------------------------------------------------------ + + def _make_plan(self, route: Route, *, recipient: str, amount_atomic: int, sender: str) -> Plan: + return build_plan(self.registry, route, amount_atomic=amount_atomic, recipient=recipient, sender=sender) + + def _compile_message(self, metadata: sl.SolanaRouteMetadata, *, sender: str, unique_message: str, + recipient32: bytes, amount_atomic: int) -> tuple[Any, str, int]: + """v0 message: [SetComputeUnitLimit(400_000), TransferRemote] with a confirmed blockhash.""" + libs = _libs() + data = sl.build_transfer_remote_instruction_data(metadata.destination_domain, recipient32, amount_atomic) + metas = [libs.AccountMeta(libs.Pubkey.from_string(m.address), is_signer=m.signer, is_writable=m.writable) + for m in sl.account_metas(metadata, sender, unique_message)] + instruction = libs.Instruction(libs.Pubkey.from_string(metadata.warp_program_address), data, metas) + latest = self.client.get_latest_blockhash(commitment=CONFIRMED).value + message = libs.MessageV0.try_compile( + libs.Pubkey.from_string(sender), + [libs.set_compute_unit_limit(sl.COMPUTE_UNIT_LIMIT), instruction], + [], + latest.blockhash, + ) + return message, str(latest.blockhash), int(latest.last_valid_block_height) + + def quote_transfer_remote(self, recipient: str | None = None, *, amount: str | None = None, + amount_atomic: int | None = None, sender: str | None = None, + plan: Plan | None = None) -> SolanaHyperlaneQuote: + """Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind + ``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required + for the fee estimate. + + ``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry + version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ + ``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so + re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + """ + libs = _libs() + route = self.outbound_route() + metadata = sl.solana_route_metadata(route) + decimals = self.registry.asset(route.source_asset_id).decimals + if plan is not None: + if sender is not None: + raise ValueError("Pass plan= or sender=, not both: the plan carries its own sender") + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"plan was prepared against registry {plan.registry_version}; this client runs {self.registry.version} — re-run quote()") + if plan.route_id != route.id: + raise UnsupportedRouteError(f"plan route {plan.route_id} is not the Solana Hyperlane route {route.id}") + _assert_amount_matches_plan(plan, amount=amount, amount_atomic=amount_atomic, decimals=decimals) + recipient, amount_atomic, amount, sender = plan.recipient, plan.amount_atomic, None, plan.sender + elif recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if amount_atomic <= 0: + raise InvalidAmountError("amount must be positive") + recipient32 = aleo_address_to_bytes32(recipient) + sender = sender or self.conn.address + if sender is None: + raise ConfigurationError("Solana sender is required to quote the transaction fee: configure a signer or pass sender=") + if plan is None: + plan = self._make_plan(route, recipient=recipient, amount_atomic=amount_atomic, sender=sender) + + igp_data = self._account_data(metadata.igp_account) + if igp_data is None: + raise BridgeError(f"Solana IGP account does not exist: {metadata.igp_account}") + igp = sl.quote_igp_lamports(igp_data, metadata.destination_domain, metadata.destination_gas_amount) + + unique = libs.Keypair() # disposable: only its pubkey seeds the fee-estimate message + message, _blockhash, _height = self._compile_message( + metadata, sender=sender, unique_message=str(unique.pubkey()), recipient32=recipient32, amount_atomic=amount_atomic) + fee_value = self.client.get_fee_for_message(message, commitment=CONFIRMED).value + if fee_value is None: + raise BridgeError("Solana RPC getFeeForMessage returned no fee (the blockhash is unknown to the node); retry") + fee = int(fee_value) + rent = sum(int(self.client.get_minimum_balance_for_rent_exemption(size).value) + for size in (sl.GAS_PAYMENT_ACCOUNT_DATA_LENGTH, sl.DISPATCHED_MESSAGE_ACCOUNT_DATA_LENGTH, 0)) + total = amount_atomic + igp + fee + rent + fees = ( + Fee("interchain-gas", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(igp, decimals), True), + Fee("network", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(fee, decimals), True), + Fee("rent", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(rent, decimals), True), + ) + return SolanaHyperlaneQuote( + kind="solana-hyperlane", plan=plan, fees=fees, amount_out=plan.amount, + igp_lamports=igp, network_fee_lamports=fee, rent_lamports=rent, total_lamports=total, + unique_message_address=str(unique.pubkey()), + ) + + # --- write ------------------------------------------------------------------------------ + + def transfer_remote(self, recipient: str | None = None, *, amount: str | None = None, + amount_atomic: int | None = None, plan: Plan | None = None) -> SolCall[DispatchReceipt]: + """Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + + Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, + ``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + + ``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for + the connected wallet; its registry version and route id are re-checked against the live registry + when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a + ``ValueError``. Without a plan, ``recipient`` is required. + """ + route = self.outbound_route() + sl.solana_route_metadata(route) # refuse inactive/malformed routes early + decimals = self.registry.asset(route.source_asset_id).decimals + if plan is not None: + _assert_amount_matches_plan(plan, amount=amount, amount_atomic=amount_atomic, decimals=decimals) + recipient, amount_atomic, amount = plan.recipient, plan.amount_atomic, None + elif recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if amount_atomic <= 0: + raise InvalidAmountError("amount must be positive") + aleo_address_to_bytes32(recipient) + + def build_result(receipt: Receipt) -> DispatchReceipt: + # The receipt's routeId comes from the route resolved at build time, which is the one the + # transaction was actually compiled against; route.id here is only the snapshot's fallback. + return DispatchReceipt(transaction_id=receipt.source_tx_id or receipt.id, + route_id=receipt.protocol_state.get("routeId") or route.id, + message_id=receipt.protocol_state.get("messageId"), + amount_atomic=amount_atomic, receipt=receipt) + + return SolCall(self, route=route, recipient=recipient, amount_atomic=amount_atomic, plan=plan, + build_result=build_result, store=getattr(self._bridge, "checkpoints", None)) + + def _build_transaction(self, *, recipient: str, amount_atomic: int, plan: Plan | None) -> SolBuild: + libs = _libs() + sender = self.conn.address + if sender is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to build transactions") + if plan is not None and plan.sender and plan.sender != sender: + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {sender}") + quote = (self.quote_transfer_remote(plan=plan) if plan is not None # the plan carries the sender + else self.quote_transfer_remote(recipient, amount_atomic=amount_atomic, sender=sender)) + # Re-resolved here rather than reusing the route snapshotted when transfer_remote() was called: + # the registry may have moved since, and compiling the instruction against a stale deployment + # while the quote priced the live one would sign a transfer to the wrong program. + route = self.outbound_route() + if route.id != quote.plan.route_id: + raise UnsupportedRouteError( + f"the Solana Hyperlane route is now {route.id}, but this transfer was quoted for " + f"{quote.plan.route_id} — re-run quote()") + metadata = sl.solana_route_metadata(route) + unique = libs.Keypair() # fresh per build: seeds the dispatched-message and gas-payment PDAs + message, blockhash, last_valid_block_height = self._compile_message( + metadata, sender=sender, unique_message=str(unique.pubkey()), + recipient32=aleo_address_to_bytes32(quote.plan.recipient), amount_atomic=quote.plan.amount_atomic) + keys = list(message.account_keys) + if keys[0] != libs.Pubkey.from_string(sender): + raise BridgeError("compiled Solana message does not list the sender as fee payer") + signatures = [libs.Signature.default()] * message.header.num_required_signatures + signatures[keys.index(unique.pubkey())] = unique.sign_message(libs.to_bytes_versioned(message)) + transaction = libs.VersionedTransaction.populate(message, signatures) + return SolBuild(quote=replace(quote, unique_message_address=str(unique.pubkey())), message=message, + transaction=transaction, unique_message_address=str(unique.pubkey()), blockhash=blockhash, + last_valid_block_height=last_valid_block_height, sender=sender, + destination_domain=metadata.destination_domain) + + def _source_receipt(self, built: SolBuild, signature: str) -> Receipt: + return Receipt( + id=signature, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=signature, + protocol_state={ + "routeId": built.quote.plan.route_id, + "signature": signature, + "uniqueMessageAddress": built.unique_message_address, + "destinationDomain": built.destination_domain, + "quotedLamports": str(built.quote.total_lamports), + "blockhash": built.blockhash, + "lastValidBlockHeight": str(built.last_valid_block_height), + }, + ) + + def _transaction_logs(self, signature: str) -> list[str] | None: + libs = _libs() + value = self.client.get_transaction(libs.Signature.from_string(signature), encoding="json", + commitment=CONFIRMED, max_supported_transaction_version=0).value + if value is None: + return None + meta = value.transaction.meta + return None if meta is None else meta.log_messages + + def _delivery_pending(self, receipt: Receipt, signature: str) -> Receipt: + """Settle a confirmed signature as DELIVERY_PENDING, with the Mailbox message id when readable. + + The logs supply nothing but the message id, and ``messageIdUnavailable`` already covers a missing + dispatch line, so a failing ``getTransaction`` degrades to that fallback instead of turning a + transfer that is already on-chain into a reported failure. + """ + try: + logs = self._transaction_logs(signature) + except Exception: # noqa: BLE001 — RPC or decode failure reading the logs + logs = None + message_id = sl.extract_hyperlane_message_id(logs) + state = dict(receipt.protocol_state) + if message_id: + state["messageId"] = message_id + else: + state["messageIdUnavailable"] = True + return receipt.replace(id=message_id or signature, status=Status.DELIVERY_PENDING, protocol_state=state) + + def _checkpoint(self, plan: Plan, receipt: Receipt, on_checkpoint: "Callable[[Checkpoint], None] | None", + store: "CheckpointStore | None", signature: str) -> None: + """Emit the checkpoint for a just-broadcast *signature* to the caller first, then the store. + + Mirrors ``EvmCall._checkpoint``: the caller's callback runs before the store because the + transaction is already on the wire; a store failure is then fatal and names the signature, + because losing it silently would strand funds. + """ + from .checkpoint import create_checkpoint + + checkpoint = create_checkpoint(plan, receipt, self.registry) + if on_checkpoint is not None: + on_checkpoint(checkpoint) # the caller's own callback: errors are theirs + if store is not None: + try: + store.save(checkpoint) + except Exception as exc: # noqa: BLE001 — any store backend failure + raise BridgeError( + f"Solana transaction {signature} WAS broadcast but its checkpoint {checkpoint.id} could not be " + f"saved ({exc}); record the signature before retrying — resending would double-spend") from exc + + def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_seconds: float, + on_checkpoint: "Callable[[Checkpoint], None] | None" = None, + store: "CheckpointStore | None" = None, + on_broadcast: "Callable[[str], None] | None" = None) -> Receipt: + libs = _libs() + quote = built.quote + balance = self._balance_of(built.sender) + if balance < quote.total_lamports: + raise InsufficientBalanceError( + f"Insufficient Solana balance for this Hyperlane transfer: balance {balance} lamports, " + f"required {quote.total_lamports} lamports (amount {quote.plan.amount_atomic} " + f"+ gas {quote.igp_lamports + quote.network_fee_lamports} + rent {quote.rent_lamports})") + signatures = list(built.transaction.signatures) + payer_index = list(built.message.account_keys).index(libs.Pubkey.from_string(built.sender)) + signatures[payer_index] = self.conn.sign_message(libs.to_bytes_versioned(built.message)) + signed = libs.VersionedTransaction.populate(built.message, signatures) + opts = SendOptions(skip_preflight=False, preflight_commitment=CONFIRMED) + # A Solana transaction's id IS its fee-payer signature, so it is known before the send. Capture it + # first: if the RPC answer is lost the bytes may still have reached the cluster, and a caller who + # never learns the signature cannot tell a failed send from a landed one (and would resend). + signature = str(signed.signatures[0]) + try: + response = self.client.send_raw_transaction(bytes(signed), opts=opts) + except Exception as exc: # noqa: BLE001 — any transport/decoding failure loses the response, not the send + if on_broadcast is not None: + on_broadcast(signature) # it may be on the wire: never let the caller resend + raise BridgeError( + f"Solana transaction {signature} may have been broadcast; the RPC response was lost: {exc}" + " — check bridge.sol.source_status / the explorer before retrying") from exc + if on_broadcast is not None: + on_broadcast(signature) + echoed = str(response.value) + if echoed != signature: + raise BridgeError( + f"Solana RPC echoed signature {echoed} for a transaction signed as {signature}; refusing to " + "checkpoint or follow the wrong id — check bridge.sol.source_status / the explorer for " + f"{signature} before retrying") + receipt = self._source_receipt(built, signature) + self._checkpoint(quote.plan, receipt, on_checkpoint, store, signature) + if not wait: + return receipt + try: + outcome = _poll_for_confirmation(self.client, signature, built.blockhash, timeout_seconds, poll_seconds) + if outcome is None: + return receipt + if outcome == "expired": + return receipt.replace(status=Status.EXPIRED, protocol_state={ + **receipt.protocol_state, "blockhashExpired": True, + "sourceError": f"Solana transaction expired before confirmation: {signature}"}) + return self._delivery_pending(receipt, signature) + except BridgeError as exc: + if signature in str(exc): + raise + raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc + except Exception as exc: # noqa: BLE001 — any post-broadcast failure names the signature + raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc + + # --- status ----------------------------------------------------------------------------- + + def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: + """One refresh of a SOURCE_CONFIRMING Solana receipt (veil ``getSourceStatus``): unknown → unchanged, or + EXPIRED once the checkpointed blockhash is invalid; processed → unchanged; failed → raises; + confirmed/finalized → DELIVERY_PENDING with the Mailbox message id when the log is available.""" + if receipt.protocol != "hyperlane" or receipt.status is not Status.SOURCE_CONFIRMING or not receipt.source_tx_id: + raise BridgeError("Solana Hyperlane source status requires a source-confirming Hyperlane receipt with a signature") + if receipt.protocol_state.get("routeId") != plan.route_id: + raise BridgeError(f"receipt route {receipt.protocol_state.get('routeId')} does not match plan route {plan.route_id}") + libs = _libs() + signature = receipt.source_tx_id + status = _signature_status(self.client, libs.Signature.from_string(signature)) + if status is None: + blockhash = receipt.protocol_state.get("blockhash") + height = receipt.protocol_state.get("lastValidBlockHeight") + if blockhash is None and height is None: + return receipt + if not isinstance(blockhash, str) or not blockhash or not isinstance(height, str) or not height.isdigit(): + raise CheckpointInvalidError("Solana Hyperlane source receipt has an invalid blockhash lifetime") + try: + hash_ = libs.Hash.from_string(blockhash) + except Exception as exc: + raise CheckpointInvalidError("Solana Hyperlane source receipt has an invalid blockhash lifetime") from exc + try: + valid = bool(self.client.is_blockhash_valid(hash_, commitment=CONFIRMED).value) + except Exception: + return receipt # advisory read; keep waiting + if not valid: + return receipt.replace(status=Status.EXPIRED, protocol_state={ + **receipt.protocol_state, "blockhashExpired": True, + "sourceError": f"Solana transaction expired before confirmation: {signature}"}) + return receipt + if status == "processed": + return receipt + if status == "failed": + raise BridgeError(f"Solana Hyperlane transfer failed on-chain: {signature}") + return self._delivery_pending(receipt, signature) diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py new file mode 100644 index 0000000..4cb2203 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -0,0 +1,271 @@ +"""Typed results shared by every module (contract §types.py). Atomic amounts are ``int``; human amounts are ``str``.""" +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from .errors import ConfigurationError + + +class Status(str, Enum): + PREPARED = "PREPARED" + SOURCE_APPROVAL_PENDING = "SOURCE_APPROVAL_PENDING" + SOURCE_SUBMISSION_PENDING = "SOURCE_SUBMISSION_PENDING" + SOURCE_CONFIRMING = "SOURCE_CONFIRMING" + ATTESTATION_PENDING = "ATTESTATION_PENDING" + DESTINATION_ACTION_REQUIRED = "DESTINATION_ACTION_REQUIRED" + DELIVERY_PENDING = "DELIVERY_PENDING" + DESTINATION_CONFIRMING = "DESTINATION_CONFIRMING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + EXPIRED = "EXPIRED" + + def __str__(self) -> str: # json.dumps and f-strings print the bare name + return self.value + + +TERMINAL = {Status.COMPLETED, Status.FAILED, Status.EXPIRED} +CALLER_BOUNDARIES = {Status.SOURCE_SUBMISSION_PENDING, Status.DESTINATION_ACTION_REQUIRED, + Status.COMPLETED, Status.FAILED, Status.EXPIRED} + +# brief §2.5 +_NEXT_BY_STATUS = { + Status.SOURCE_SUBMISSION_PENDING: "resume", + Status.DESTINATION_ACTION_REQUIRED: "complete", + Status.COMPLETED: "done", + Status.FAILED: "failed", + Status.EXPIRED: "failed", +} + + +@dataclass(frozen=True) +class Step: + id: str + kind: str # approve|deposit|burn|dispatch|wait-attestation|mint|withdraw|wait-delivery|confirm-delivery + executor: str # aleo-wallet|evm-wallet|solana-wallet|protocol + irreversible: bool + + +@dataclass(frozen=True) +class Fee: + kind: str + chain_id: str + asset_id: str + amount: str + estimated: bool + + +@dataclass(frozen=True) +class Plan: + route_id: str + registry_version: str + protocol: str + environment: str + source_asset_id: str + destination_asset_id: str + amount: str + amount_atomic: int + recipient: str + sender: str | None + mint_mode: str # "public" | "record" | "private" + steps: tuple[Step, ...] + + def to_dict(self) -> dict[str, Any]: + d = dataclasses.asdict(self) + d["steps"] = [dataclasses.asdict(s) for s in self.steps] + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "Plan": + data = dict(d) + data["steps"] = tuple(Step(**s) for s in data.get("steps", ())) + return cls(**data) + + +@dataclass(frozen=True) +class Quote: + kind: str # evm-hyperlane | solana-hyperlane | aleo-hyperlane | evm-xreserve | aleo-xreserve + plan: Plan + fees: tuple[Fee, ...] + amount_out: str | None + + +@dataclass(frozen=True) +class EvmHyperlaneQuote(Quote): + recipient_bytes32: bytes + native_value_atomic: int + native_fee_atomic: int + approval_required: bool | None + + +@dataclass(frozen=True) +class SolanaHyperlaneQuote(Quote): + igp_lamports: int + network_fee_lamports: int + rent_lamports: int + total_lamports: int + unique_message_address: str + + +@dataclass(frozen=True) +class AleoHyperlaneQuote(Quote): + gas_limit: int + gas_overhead: int + gas_price: int + exchange_rate: int + payment_microcredits: int + + +@dataclass(frozen=True) +class EvmXReserveQuote(Quote): + hook_data: bytes + remote_recipient_bytes32: bytes + balance_atomic: int + allowance_atomic: int + approval_required: bool + max_fee_atomic: int + + +@dataclass(frozen=True) +class AleoXReserveQuote(Quote): + withdrawal_fee_atomic: int + + +@dataclass +class Receipt: + id: str + protocol: str + status: Status + source_tx_id: str | None = None + destination_tx_id: str | None = None + protocol_state: dict[str, Any] = field(default_factory=dict) # MUST include "routeId" + next_action: dict[str, Any] | None = None # {"kind": "xreserve-private-mint", "chainId": ...} + + def __post_init__(self) -> None: + self.status = Status(self.status) + + def replace(self, **changes: Any) -> "Receipt": + # Copy the mutable fields (never alias self's dicts) so mutating the returned Receipt + # can never reach back into the one it was derived from. + changes.setdefault("protocol_state", dict(self.protocol_state)) + changes.setdefault("next_action", None if self.next_action is None else dict(self.next_action)) + return dataclasses.replace(self, **changes) + + +@dataclass(frozen=True) +class Progress: + next: str # "wait" | "resume" | "complete" | "done" | "failed" + plan: Plan + receipt: Receipt + error: str | None = None + + +def to_progress(plan: Plan, receipt: Receipt) -> Progress: + """brief §2.5: SOURCE_SUBMISSION_PENDING→resume, DESTINATION_ACTION_REQUIRED→complete, COMPLETED→done, + FAILED|EXPIRED→failed, everything else→wait. For FAILED|EXPIRED, derive error from protocol_state.""" + if "routeId" not in receipt.protocol_state: + raise ConfigurationError("Receipt.protocol_state must carry routeId") + status = Status(receipt.status) + next_val = _NEXT_BY_STATUS.get(status, "wait") + + # Derive error for terminal failure states + error = None + if status in {Status.FAILED, Status.EXPIRED}: + error = (receipt.protocol_state.get("destinationError") or + receipt.protocol_state.get("sourceError") or + f"Bridge transfer ended in {status.value}") + + return Progress(next_val, plan, receipt, error=error) + + +@dataclass(frozen=True) +class GasQuote: + route_id: str + gas_limit: int + gas_overhead: int + gas_price: int + exchange_rate: int + payment_microcredits: int + + +@dataclass(frozen=True) +class Attestation: + payload: bytes # 305 bytes + message_hash: bytes # 32 bytes + attestation: bytes # 65 bytes + status: str # "complete" + + +@dataclass(frozen=True) +class DispatchReceipt: + transaction_id: str + route_id: str + message_id: str | None + amount_atomic: int + receipt: Receipt + + +@dataclass(frozen=True) +class BurnReceipt: + transaction_id: str + route_id: str + mode: str + amount_atomic: int + receipt: Receipt + + +@dataclass(frozen=True) +class MintReceipt: + transaction_id: str + route_id: str + receipt: Receipt + + +@dataclass(frozen=True) +class DepositReceipt: + transaction_id: str + route_id: str + message_hash: str + nonce: str + receipt: Receipt + + +@dataclass(frozen=True) +class PrivacyReceipt: + transaction_id: str + asset_id: str + amount: str + amount_atomic: int + direction: str # "shield" | "unshield" + + +@dataclass(frozen=True) +class PreparedTx: + transaction_id: str + serialized: str # Transaction JSON; rebroadcast via network.submit_transaction(serialized) + + +@dataclass +class ChainStatus: + chain_id: str + address: str | None + can_sign: bool + balances: dict[str, int] # asset_id -> atomic + + +@dataclass +class BridgeStatus: + environment: str + registry_version: str + chains: list[ChainStatus] + pending: list[Progress] + + +__all__ = [ + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", +] diff --git a/bridge-sdk/python/aleo_bridge/units.py b/bridge-sdk/python/aleo_bridge/units.py new file mode 100644 index 0000000..fc276ed --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/units.py @@ -0,0 +1,60 @@ +"""Exact decimal ↔ atomic conversion (port of veil ``utils/units.ts``). No floats anywhere.""" +from __future__ import annotations + +import re +from decimal import Decimal + +from .errors import InvalidAmountError + +_DECIMAL_RE = re.compile(r"^([0-9]+)(?:\.([0-9]+))?$") + + +def _check_decimals(decimals: int) -> None: + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise InvalidAmountError(f"Asset decimals must be a non-negative integer, got {decimals!r}") + + +def parse_decimal_amount(amount: "str | Decimal | int", decimals: int) -> int: + """``"0.5"`` with 6 decimals → ``500000``. Strict: digits with an optional fraction only. + + Rejects exponents, signs, a trailing dot, the empty string, floats, and any fraction longer + than *decimals* (that precision cannot exist on chain). + """ + _check_decimals(decimals) + if isinstance(amount, bool) or not isinstance(amount, (str, int, Decimal)): + raise InvalidAmountError(f"Amount must be a decimal string, int or Decimal, got {type(amount).__name__}") + text = format(amount, "f") if isinstance(amount, Decimal) else str(amount).strip() + match = _DECIMAL_RE.match(text) + if not match: + raise InvalidAmountError(f'Invalid decimal amount "{amount}" — use digits with an optional fraction, e.g. "0.5"') + whole, frac = match.group(1), match.group(2) or "" + if len(frac) > decimals: + raise InvalidAmountError( + f'Amount "{amount}" has {len(frac)} fractional digits but the asset supports {decimals}') + return int(whole + frac.ljust(decimals, "0")) + + +def format_decimal_amount(atomic: int, decimals: int) -> str: + """``2000001`` with 6 decimals → ``"2.000001"``; trailing fractional zeros are stripped.""" + _check_decimals(decimals) + if isinstance(atomic, bool) or not isinstance(atomic, int) or atomic < 0: + raise InvalidAmountError(f"Atomic amount must be a non-negative int, got {atomic!r}") + if decimals == 0: + return str(atomic) + digits = str(atomic).rjust(decimals + 1, "0") + whole, fraction = digits[:-decimals], digits[-decimals:].rstrip("0") + return f"{whole}.{fraction}" if fraction else whole + + +def resolve_amount(*, amount: "str | Decimal | int | None", amount_atomic: "int | None", decimals: int) -> int: + """Exactly one of *amount* (human) / *amount_atomic* (int) → atomic int.""" + if (amount is None) == (amount_atomic is None): + raise InvalidAmountError("Pass exactly one of amount= (decimal string) or amount_atomic= (int)") + if amount_atomic is not None: + if isinstance(amount_atomic, bool) or not isinstance(amount_atomic, int) or amount_atomic < 0: + raise InvalidAmountError(f"amount_atomic must be a non-negative int, got {amount_atomic!r}") + return amount_atomic + return parse_decimal_amount(amount, decimals) # type: ignore[arg-type] + + +__all__ = ["parse_decimal_amount", "format_decimal_amount", "resolve_amount"] diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py new file mode 100644 index 0000000..c431891 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -0,0 +1,193 @@ +"""Aleo side of Circle xReserve (port of veil protocols/xreserve/aleoToEvm.ts, the ``complete`` half of +evmToAleo.ts, and utils/xreserveDelivery.ts): USDCx burns, the user-signed private mint, attestation +lookup and the ``nullifier`` delivery read.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from . import encoding as enc +from ._calls import AleoCall +from ._keccak import keccak256 +from .circle import CircleClient +from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) +from .registry import Route +from .types import Attestation, BurnReceipt, MintReceipt, Receipt, Status +from .units import format_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +ETHEREUM_DESTINATION_DOMAIN = 0 +BURN_MODES = ("private", "public", "public-as-signer") + + +class XReserveModule: + """``bridge.xreserve`` — every xReserve step that happens on Aleo.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + self.circle_session: Any = None # injectable HTTP session (tests); None → requests.Session() + + # ── routes ── + def _single(self, direction: str) -> Route: + registry, aleo = self._bridge.registry, self._bridge.aleo_chain().id + matches = [r for r in registry.routes(protocol="xreserve", include_unavailable=True, environment=self._bridge.environment) + if registry.asset(r.destination_asset_id if direction == "inbound" else r.source_asset_id).chain_id == aleo] + if not matches: + raise RouteNotFoundError(f"No {direction} xReserve route for {self._bridge.environment}") + if len(matches) > 1: + raise ConfigurationError(f"Several {direction} xReserve routes for {self._bridge.environment}: {[r.id for r in matches]}") + if not matches[0].active: + raise RouteUnavailableError(f"xReserve route is not executable: {matches[0].id}") + return matches[0] + + def inbound_route(self) -> Route: + """Ethereum → Aleo (mint side) for this environment.""" + return self._single("inbound") + + def outbound_route(self) -> Route: + """Aleo → Ethereum (burn side) for this environment.""" + return self._single("outbound") + + def _validated(self, route: Route, *, direction: str) -> Route: + registry = self._bridge.registry + if route.protocol != "xreserve": + raise UnsupportedRouteError(f"Not an xReserve route: {route.id}") + if not route.active: + raise RouteUnavailableError(f"xReserve route is not executable: {route.id}") + source = registry.chain(registry.asset(route.source_asset_id).chain_id).family + destination = registry.chain(registry.asset(route.destination_asset_id).chain_id).family + if direction == "burn" and (source, destination) != ("aleo", "evm"): + raise UnsupportedRouteError(f"USDCx burn requires an Aleo-to-Ethereum route, got {route.id}") + if direction == "mint" and (source, destination) != ("evm", "aleo"): + raise UnsupportedRouteError(f"private_mint requires an Ethereum-to-Aleo route, got {route.id}") + if route.meta_int("ethereumDestinationDomain") != ETHEREUM_DESTINATION_DOMAIN: + raise ConfigurationError(f"xReserve Ethereum destination domain must be {ETHEREUM_DESTINATION_DOMAIN}: {route.id}") + return route + + # ── burn ── + def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, recipient: str, + record: str | None, merkle_proof: str | None) -> tuple[str, str, list[str]]: + """``(program, function, inputs)`` for one USDCx burn (brief §3.4). Pure.""" + if mode not in BURN_MODES: + raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + self._validated(route, direction="burn") + source = self._bridge.registry.asset(route.source_asset_id) + if amount_atomic <= 0: + raise InvalidAmountError("USDCx burn amount must be greater than zero") + fee = int(route.meta_str("withdrawalFeeAtomic")) + if amount_atomic <= fee: + raise InvalidAmountError( + f"USDCx burn amount must exceed the {format_decimal_amount(fee, source.decimals)} {source.symbol} withdrawal fee") + recipient32 = enc.evm_address_to_bytes32(recipient) # InvalidRecipientError + amount_lit, domain_lit, recipient_lit = f"{amount_atomic}u128", f"{ETHEREUM_DESTINATION_DOMAIN}u32", enc.u8_array_literal(recipient32) + if mode == "private": + if not isinstance(record, str) or not record.strip(): + raise ConfigurationError(f"private_burn requires a USDCx Token record from {route.meta_str('remoteToken')}") + if not isinstance(merkle_proof, str) or not (merkle_proof.startswith("[") and merkle_proof.endswith("]")): + raise ConfigurationError("private_burn requires an encoded [MerkleProof; 2] Aleo literal") + return route.meta_str("wrapperProgram"), "private_burn", [record, amount_lit, domain_lit, recipient_lit, merkle_proof] + function = "burn_public" if mode == "public" else "burn_public_as_signer" + 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) -> AleoCall[BurnReceipt]: + """Burn USDCx for USDC on Ethereum. ``private`` (default) 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.""" + if mode not in BURN_MODES: + raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + 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'") + route = self._validated(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) + if mode == "private": + token_program = route.meta_str("remoteToken") + if record is None: + privacy = getattr(self._bridge, "privacy", None) + if privacy is None: + raise ConfigurationError( + "burn(mode='private') needs a record; pass record= explicitly (bridge.privacy is not available yet)") + record = privacy.select_record(token_program, atomic) + if merkle_proof is None: + freezelist = getattr(self._bridge, "freezelist", None) + if freezelist is None: + raise ConfigurationError( + "burn(mode='private') needs merkle_proof; pass merkle_proof= 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, + record=record, merkle_proof=merkle_proof) + recipient_hex = enc.to_hex(enc.evm_address_to_bytes32(recipient)) + + def build(tx_id: str, _outputs: list[str]) -> BurnReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": route.id, "burnMode": mode, "amountAtomic": str(atomic), + "nativeDomain": ETHEREUM_DESTINATION_DOMAIN, "nativeRecipientBytes32": recipient_hex, + "sourceProgram": program, "sourceFunction": function, + "forwardingService": "aleo-burn-attestation"}) + return BurnReceipt(transaction_id=tx_id, route_id=route.id, mode=mode, amount_atomic=atomic, receipt=receipt) + + return self._bridge._call(program, function, inputs, build) + + # ── private mint ── + def hook_data(self, mode: str, recipient: str, secret_nonce: str = "0scalar") -> bytes: + return enc.xreserve_hook_data(mode, recipient, self._bridge.environment, secret_nonce) + + def build_private_mint_inputs(self, route: Route, attestation: Attestation, recipient: str, secret_nonce: str) -> list[str]: + """The five ``private_mint`` literals (brief §3.5) after re-verifying hash and the (recipient, nonce) commitment.""" + self._validated(route, direction="mint") + enc.validate_scalar(secret_nonce) + if attestation.status != "complete": + raise AttestationError("Private mint requires a completed Circle attestation; it is still pending") + payload, signature, digest = bytes(attestation.payload), bytes(attestation.attestation), bytes(attestation.message_hash) + if len(payload) != enc.XRESERVE_PAYLOAD_BYTES or len(signature) != enc.HOOK_DATA_BYTES or len(digest) != 32: + raise AttestationError("Circle attestation has invalid widths (expected 305-byte payload, 65-byte signature, 32-byte hash)") + if keccak256(payload) != digest: + raise AttestationError("Circle attestation payload has an invalid message hash") + expected_hook = self.hook_data("private", recipient, secret_nonce) + if payload[-enc.HOOK_DATA_BYTES:] != expected_hook: + raise AttestationError("Private mint secret nonce and recipient do not match the attested hook data") + return [enc.u8_array_literal(payload), enc.u8_array_literal(signature), enc.u8_array_literal(digest), secret_nonce, recipient] + + def private_mint(self, attestation: Attestation, recipient: str, *, secret_nonce: str = "0scalar", + route: Route | None = None) -> AleoCall[MintReceipt]: + """Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``).""" + route = self._validated(route if route is not None else self.inbound_route(), direction="mint") + inputs = self.build_private_mint_inputs(route, attestation, recipient, secret_nonce) + program = route.meta_str("wrapperProgram") + message_hash = enc.to_hex(attestation.message_hash) + nonce = enc.to_hex(enc.xreserve_nonce_from_payload(attestation.payload)) + + def build(tx_id: str, _outputs: list[str]) -> MintReceipt: + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.DESTINATION_CONFIRMING, destination_tx_id=tx_id, + protocol_state={"routeId": route.id, "mintMode": "private", "intendedRecipient": recipient, + "messageHash": message_hash, "nonce": nonce, + "bridgeProgram": route.meta_str("bridgeProgram"), "wrapperProgram": program, + "destinationProgram": program, "destinationFunction": "private_mint"}) + return MintReceipt(transaction_id=tx_id, route_id=route.id, receipt=receipt) + + return self._bridge._call(program, "private_mint", inputs, build) + + # ── reads ── + def get_attestation(self, message_hash: "str | bytes", *, route: Route | None = None) -> Attestation | None: + """One Circle request for *message_hash*; ``None`` while pending (404).""" + route = route if route is not None else self.inbound_route() + client = CircleClient(route.meta_str("attestationBaseUrl"), session=self.circle_session) + return client.get_attestation(enc.to_hex(enc.hex_to_bytes(message_hash))) + + def is_delivered(self, nonce: "str | bytes", *, route: Route | None = None) -> bool: + """``bridgeProgram/nullifier[nonce as [u8; 32]] == true`` — the mint already landed on Aleo.""" + route = route if route is not None else self.inbound_route() + try: + raw = enc.hex_to_bytes(nonce, 32) + except (ValueError, InvalidRecipientError) as exc: + raise ConfigurationError("xReserve delivery requires a 32-byte deposit nonce") from exc + value = self._bridge.mapping_value(route.meta_str("bridgeProgram"), "nullifier", enc.u8_array_literal(raw)) + return value is not None and value.strip() == "true" + + +__all__ = ["BURN_MODES", "ETHEREUM_DESTINATION_DOMAIN", "XReserveModule"] diff --git a/bridge-sdk/scripts/rehearse.py b/bridge-sdk/scripts/rehearse.py new file mode 100644 index 0000000..269aba2 --- /dev/null +++ b/bridge-sdk/scripts/rehearse.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Operator front-end over the live cases in ``tests/live/cases.py``. + + python scripts/rehearse.py --case evm-hyperlane --quote-only # price every route, submit nothing + python scripts/rehearse.py --case evm-hyperlane --route hyperlane:ethereum/eth->aleo/eth + python scripts/rehearse.py --recover # continue one interrupted transfer + python scripts/rehearse.py --case solana-hyperlane --report run.json + +The CLI and the pytest suite call the SAME case functions, so a rehearsal and a test cannot drift. + +This script never sets, exports, prints or suggests a value for an acknowledgement variable. It +READS ``BRIDGE_LIVE_FUNDS`` / ``BRIDGE_LIVE_STATE_DIR`` / ``BRIDGE_LIVE_MAINNET_ACK`` / +``BRIDGE_LIVE_MAINNET_CASES`` / ``BRIDGE_LIVE_MAINNET_EXECUTE``; without all of them it runs to the +quote and reports what it would have submitted. Keys, secret nonces, attestations and record +plaintexts never reach the output — addresses, amounts and transaction ids do. + +Exit codes: ``0`` everything ran (or was quoted/skipped), ``1`` a case failed, ``2`` a case is +still pending (a timeout leaves the checkpoint on disk; re-run with ``--recover``). +""" +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Iterable + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(ROOT / "python") not in sys.path: + sys.path.insert(0, str(ROOT / "python")) + +from aleo_bridge.errors import BridgeError, PollingTimeoutError # noqa: E402 +from tests.live import cases as live_cases # noqa: E402 +from tests.live import config as live_config # noqa: E402 +from tests.live.helpers import (LiveBenchmark, LiveCaseError, LiveTimeoutError, # noqa: E402 + Underfunded, load_live_state) + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_PENDING = 2 + +_COLUMNS = (("case", 16), ("route_id", 40), ("status", 11), ("source_tx_id", 18), ("reason", 40)) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="rehearse.py", description="Run one live bridge case over its registry routes.", + epilog="Mainnet submission additionally requires the acknowledgement variables described " + "in the README's 'Live tests' section; this script only reads them.") + parser.add_argument("--case", choices=list(live_cases.CASE_NAMES), + help="the veil case to run (omit only with --recover)") + parser.add_argument("--route", help="restrict the run to one registry route id") + parser.add_argument("--quote-only", action="store_true", + help="price and preflight every route; never submit, whatever is acknowledged") + parser.add_argument("--recover", metavar="STATE.json", + help="continue the transfer recorded in one state file (its route picks the case)") + parser.add_argument("--report", metavar="PATH", help="write the JSON report here") + args = parser.parse_args(argv) + if not args.case and not args.recover: + parser.error("give --case NAME or --recover STATE.json") + return args + + +# ── target selection ────────────────────────────────────────────────────────── + +def resolve_routes(registry: Any, case: str, route: str | None = None, + environment: str = "mainnet") -> list[Any]: + """The routes this invocation covers: every route of *case*, or the single one asked for.""" + routes = live_cases.routes_for_case(registry, case, environment) + if route is None: + return routes + chosen = [candidate for candidate in routes if candidate.id == route] + if not chosen: + raise SystemExit(f"Route {route} is not one of the {case} routes for {environment}") + return chosen + + +def execution_allowed(case: str, *, quote_only: bool) -> tuple[bool, str]: + """Whether the wallet may submit, and the reason when it may not (variable names only).""" + if quote_only: + return False, "--quote-only was given" + if not live_config.live_funds_enabled(): + return False, (f"{live_config.FUNDS_VAR} and {live_config.STATE_DIR_VAR} do not enable " + "funded live cases") + if not live_config.mainnet_case_enabled(case): + return False, (f"{live_config.MAINNET_ACK_VAR} and {live_config.MAINNET_CASES_VAR} do not " + f"enable the {case} case") + if not live_config.mainnet_execution_enabled(): + return False, f"{live_config.MAINNET_EXECUTE_VAR} does not acknowledge mainnet submission" + return True, "acknowledged" + + +def state_path_for(case: str, route_id: str, environment: str) -> Path: + return live_config.live_state_path(environment, live_cases.state_name(case, route_id)) + + +def resume_command(state_path: Path | str) -> str: + return f"python scripts/rehearse.py --recover {state_path}" + + +# ── running ─────────────────────────────────────────────────────────────────── + +def _row(case: str, route_id: str, status: str, reason: str = "", state: Any = None, + state_path: Path | str | None = None, benchmark: LiveBenchmark | None = None) -> dict[str, Any]: + return { + "case": case, + "route_id": route_id, + "status": status, + "reason": reason, + "source_tx_id": getattr(state, "source_tx_id", None), + "message_id": getattr(state, "message_id", None), + "destination_tx_id": getattr(state, "destination_tx_id", None), + "state_path": str(state_path) if state_path else None, + "resume": resume_command(state_path) if state_path else "", + "benchmark": benchmark.as_dict() if benchmark is not None else {}, + } + + +def run_route(bridge: Any, case: str, route: Any, *, execute: bool, state_path: Path, + log: Callable[[str], None] = print) -> dict[str, Any]: + """One route: run the case, and turn every outcome into a report row rather than a traceback.""" + if not route.active: + return _row(case, route.id, "skipped", f"registry availability: {route.availability}") + benchmark = LiveBenchmark(f"{case}:{live_cases.route_slug(route.id)}", log=log) + runner = live_cases.RUNNERS[case] + try: + state = runner(bridge, route.id, state_path=state_path, execute=execute, benchmark=benchmark, + log=log) + except Underfunded as exc: + return _row(case, route.id, "skipped", str(exc), state_path=state_path, benchmark=benchmark) + except (PollingTimeoutError, LiveTimeoutError) as exc: + return _row(case, route.id, "pending", f"still in flight: {exc}", state_path=state_path, + benchmark=benchmark) + except (LiveCaseError, BridgeError) as exc: + return _row(case, route.id, "failed", f"{type(exc).__name__}: {exc}", state_path=state_path, + benchmark=benchmark) + status = "completed" if state.completed else ("quote-only" if not execute else "pending") + reason = "" if status != "pending" else "the transfer has not reached done yet" + return _row(case, route.id, status, reason, state=state, state_path=state_path, benchmark=benchmark) + + +def _cell(value: Any, width: int) -> str: + text = str(value or "") + return text.ljust(width) if len(text) <= width else text[:width - 1] + "…" + + +def render_table(rows: Iterable[dict[str, Any]]) -> str: + """A fixed-width table of what ran, was skipped, is pending, or failed.""" + header = " ".join(name.replace("_", " ").upper().ljust(width) for name, width in _COLUMNS) + lines = [header, "-" * len(header)] + lines.extend(" ".join(_cell(row.get(name), width) for name, width in _COLUMNS) for row in rows) + return "\n".join(lines) + "\n" + + +def exit_code(rows: list[dict[str, Any]]) -> int: + statuses = {row["status"] for row in rows} + if "failed" in statuses: + return EXIT_FAILED + if "pending" in statuses: + return EXIT_PENDING + return EXIT_OK + + +def _default_bridge() -> Any: + from aleo_bridge import Bridge + + # Bridge.from_env() already reads BRIDGE_LIVE_ETHEREUM_RPC_URL / BRIDGE_LIVE_SOLANA_RPC_URL as + # aliases of ETHEREUM_RPC_URL / SOLANA_RPC_URL, so veil's shell works unchanged. + return Bridge.from_env() + + +def run(argv: list[str] | None = None, *, bridge_factory: Callable[[], Any] = _default_bridge, + log: Callable[[str], None] = print) -> int: + args = parse_args(argv) + run_benchmark = LiveBenchmark("rehearse", log=log) + bridge = bridge_factory() + run_benchmark.mark("clients-created") + environment = bridge.environment + + if args.recover: + state_path = Path(args.recover).expanduser().resolve() + route_id = json.loads(state_path.read_text(encoding="utf-8")).get("routeId") + if not isinstance(route_id, str): + raise SystemExit(f"{state_path} does not name a routeId") + route = bridge.registry.route(route_id) + case = args.case or live_cases.case_for_route(bridge.registry, route) + if case is None: + raise SystemExit(f"No live case covers {route_id}") + load_live_state(state_path, route_id) # fail closed before anything else + targets = [(route, state_path)] + else: + case = args.case + targets = [(route, state_path_for(case, route.id, environment)) + for route in resolve_routes(bridge.registry, case, args.route, environment)] + + execute, reason = execution_allowed(case, quote_only=args.quote_only) + log(f"case {case} · environment {environment} · registry {bridge.registry.version}") + log(f"submission: {'ENABLED' if execute else 'disabled'} ({reason})") + if not execute and not args.quote_only: + log("Nothing will be submitted. Mainnet submission is gated on the acknowledgement variables " + f"({live_config.MAINNET_ACK_VAR}, {live_config.MAINNET_CASES_VAR}, " + f"{live_config.MAINNET_EXECUTE_VAR}); set them yourself, for one command, in your own shell.") + + rows = [run_route(bridge, case, route, execute=execute, state_path=path, log=log) + for route, path in targets] + + log("") + log(render_table(rows)) + for row in rows: + if row["status"] == "pending": + log(f"pending {row['route_id']}: {row['resume']}") + + payload = {"generated_at": datetime.now(timezone.utc).isoformat(), "case": case, + "environment": environment, "registry_version": bridge.registry.version, + "execute": execute, "reason": reason, "results": rows, + "run_benchmark": run_benchmark.as_dict()} + if args.report: + report = Path(args.report).expanduser() + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + log(f"report written to {report}") + return exit_code(rows) + + +def main() -> int: + return run() + + +if __name__ == "__main__": # pragma: no cover — exercised through run() + raise SystemExit(main()) diff --git a/bridge-sdk/tests/__init__.py b/bridge-sdk/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py new file mode 100644 index 0000000..8161ba0 --- /dev/null +++ b/bridge-sdk/tests/conftest.py @@ -0,0 +1,252 @@ +"""Hermetic stand-ins for the aleo facade. Records every call so tests assert on exact inputs.""" +from __future__ import annotations + +import json +from typing import Any + +import pytest + +SIGNER = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +IGP = "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" +IGP_KEY_ETH = f"{{ igp: {IGP}, destination: 1u32 }}" +IGP_KEY_SOL = f"{{ igp: {IGP}, destination: 1399811149u32 }}" +ETH_GAS_CONFIG = "{\n gas_overhead: 159337u128,\n exchange_rate: 402u128,\n gas_price: 1000000000u128\n}" +SOL_GAS_CONFIG = "{ gas_overhead: 200000u128, exchange_rate: 1000u128, gas_price: 50000000u128 }" +USDCX_RECORD = f"{{ owner: {SIGNER}.private, amount: 5000000u128.private, _nonce: 7group.public }}" +USDCX_RECORD_SMALL = f"{{ owner: {SIGNER}.private, amount: 100u128.private, _nonce: 8group.public }}" +DELIVERED_KEY = "{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }" +NULLIFIED_NONCE = bytes.fromhex("aa" * 32) +CHILD = {"program": "usdcx_stablecoin.aleo", "function": "transfer_private_to_public", "outputs": [{"value": "1field"}]} + + +def _source(program_id: str) -> str: + return f"program {program_id};\nfunction main:\n input r0 as u64.public;\n output r0 as u64.public;\n" + + +class FakeAccount: + def __init__(self, address: str = SIGNER) -> None: + self.address = address + self.private_key = "APrivateKey1zkpFake" + + +class FakeMapping: + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + + def get(self, key: Any) -> Any: + return self._values.get(str(key)) # None ≙ absent/null mapping entry + + +class FakeTransition: + def __init__(self, program: str, function: str, outputs: list) -> None: + self.program_id, self.function_name, self._outputs = program, function, outputs + + def outputs(self) -> list: + return list(self._outputs) + + +def _tx_json(tx_id: str, program: str, function: str, outputs: list) -> dict: + return {"id": tx_id, "type": "execute", "execution": {"transitions": [ + dict(CHILD), {"program": program, "function": function, "outputs": outputs}]}} + + +class FakeTx: + def __init__(self, tx_id: str, program: str, function: str, outputs: list) -> None: + self.id = tx_id + self._json = _tx_json(tx_id, program, function, outputs) + self.raw = json.dumps(self._json) # str(raw) is what the node accepts + + def transitions(self) -> list[FakeTransition]: + return [FakeTransition(t["program"], t["function"], t["outputs"]) for t in self._json["execution"]["transitions"]] + + def decoded(self) -> list[dict]: + return [{"program": t.program_id, "function": t.function_name, "outputs": t.outputs()} for t in self.transitions()] + + +class FakeBound: + def __init__(self, aleo: "FakeAleo", program_id: str, function_name: str, args: tuple) -> None: + self._aleo, self.program_id, self.function_name = aleo, program_id, function_name + self.args = [str(a) for a in args] + aleo.calls.append((program_id, function_name, list(self.args))) + + def simulate(self, account: Any = None) -> str: + self._aleo.simulated.append((self.program_id, self.function_name)) + return "simulated" + + def build_transaction(self, account: Any = None, **fee: Any) -> FakeTx: + self._aleo.fee_kwargs.append(dict(fee)) + return FakeTx("at1built", self.program_id, self.function_name, [{"value": "77field"}]) + + def delegate(self, account: Any = None, *, broadcast: bool = True, **fee: Any) -> dict: + self._aleo.delegated.append({"program": self.program_id, "function": self.function_name, "broadcast": broadcast, **fee}) + if self._aleo.delegate_returns_id_only: + return {"transaction_id": "at1delegated"} + return {"transaction": _tx_json("at1delegated", self.program_id, self.function_name, [{"value": "77field"}])} + + +class FakeFunctions: + def __init__(self, aleo: "FakeAleo", program_id: str) -> None: + self._aleo, self._program_id = aleo, program_id + + def __getitem__(self, name: str): + return lambda *args: FakeBound(self._aleo, self._program_id, name, args) + + __getattr__ = __getitem__ + + +class FakeProgram: + def __init__(self, aleo: "FakeAleo", program_id: str) -> None: + self.id = program_id + self.source = _source(program_id) + self.imports = list(aleo.imports.get(program_id, [])) + self.raw = ("raw", program_id) + self.functions = FakeFunctions(aleo, program_id) + self._mappings = aleo.mappings.get(program_id, {}) + + def mapping(self, name: str) -> FakeMapping: + return FakeMapping(self._mappings.get(name, {})) + + def mappings(self) -> list[str]: + return sorted(self._mappings) + + +class FakePrograms: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def get(self, program_id: str) -> FakeProgram: + if program_id in self._aleo.missing_programs: + from aleo.facade.errors import ProgramNotFound + raise ProgramNotFound(program_id) + self._aleo.fetched.append(program_id) + return FakeProgram(self._aleo, program_id) + + +class FakeRecords: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def find(self, account: Any = None, *, program: str | None = None, record: str | None = None, + unspent: bool = True, **_: Any) -> list[dict]: + self._aleo.record_queries.append({"program": program, "record": record, "unspent": unspent}) + return [dict(r) for r in self._aleo.record_rows if program is None or r.get("program") == program] + + +class FakeNetwork: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def submit_transaction(self, transaction: Any) -> str: + self._aleo.submitted.append(transaction) + if self._aleo.duplicate_on_submit: + from aleo.facade.errors import AleoNetworkError + # Mirrors the real facade: a node's duplicate-broadcast rejection is an AleoNetworkError + # whose message contains "already exists" (matched by is_duplicate_submission), not a + # bare RuntimeError. + raise AleoNetworkError("Transaction 'at1prepared' already exists in the ledger") + if isinstance(transaction, str): + return str(json.loads(transaction)["id"]) + return str(getattr(transaction, "id", "at1built")) + + def wait_for_transaction(self, tx_id: str, *, timeout: float = 45.0, poll_interval: float = 2.0) -> dict: + self._aleo.waited.append((tx_id, timeout)) + if self._aleo.wait_raises: + from aleo.facade.errors import TransactionConfirmationTimeout + raise TransactionConfirmationTimeout(tx_id, timeout) + return {"status": "accepted"} + + 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_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.""" + self._aleo.confirmed_transaction_queries.append(tx_id) + if tx_id in self._aleo.confirmed_transactions: + return self._aleo.confirmed_transactions[tx_id] + from aleo.facade.errors import TransactionNotFound + raise TransactionNotFound(tx_id) + + +class FakeProcess: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def contains_program(self, program_id: Any) -> bool: + return str(program_id) in self._aleo.registered + + def add_program(self, program: Any) -> None: + self._aleo.registered.append(str(program[1]) if isinstance(program, tuple) else str(program)) + + +class FakeAleo: + """Facade stand-in: mappings keyed program → mapping → key; records; network; process; recorders.""" + + def __init__(self, mappings: dict | None = None, records: list[dict] | None = None, + network_name: str = "mainnet", default_account: Any = None, imports: dict | None = None, + missing_programs: "set[str] | None" = None) -> None: + self.network_name = network_name + self.default_account = FakeAccount() if default_account is None else default_account + self.mappings = mappings or {} + # ``records`` is the module (aleo.records.find); the rows it returns live in ``record_rows``. + self.record_rows = records if records is not None else [{"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD}] + self.imports = imports or {} + # program ids that raise ProgramNotFound from programs.get() instead of returning a FakeProgram. + self.missing_programs = set(missing_programs or ()) + self.calls: list = [] + self.simulated: list = [] + self.delegated: list = [] + self.fee_kwargs: list = [] + self.submitted: list = [] + self.waited: list = [] + self.fetched: list = [] + self.registered: list = [] + self.record_queries: 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 + self.delegate_returns_id_only = False + self.wait_raises = False + self.programs = FakePrograms(self) + self.records = FakeRecords(self) + self.record_provider = self.records + self.network = FakeNetwork(self) + self.process = FakeProcess(self) + + +class FakeNetModule: + """Stands in for aleo. inside AleoCall's import registration (no real parsing).""" + + class Program: + @staticmethod + def from_source(source: str): + return ("raw", source.split(";")[0].removeprefix("program ")) + + class ProgramID: + @staticmethod + def from_string(value: str) -> str: + return value + + +def default_mappings() -> dict: + return { + "hyp_hook_manager.aleo": {"destination_gas_configs": {IGP_KEY_ETH: ETH_GAS_CONFIG, IGP_KEY_SOL: SOL_GAS_CONFIG}}, + "hyp_mailbox.aleo": {"deliveries": {DELIVERED_KEY: "{ block_height: 1u32 }"}}, + "usdcx_bridge_v2.aleo": {"nullifier": {"[" + ",".join(f"{b}u8" for b in NULLIFIED_NONCE) + "]": "true"}}, + "credits.aleo": {"account": {SIGNER: "2392443u64"}}, + "usdcx_stablecoin.aleo": {"balances": {SIGNER: "1000000u128"}, "freeze_list": {}, "freeze_list_last_index": {}}, + "arc20_wbtc.aleo": {"balances": {SIGNER: "10000u128"}}, + "arc20_eth.aleo": {"balances": {}}, + } + + +@pytest.fixture +def fake_aleo(monkeypatch) -> FakeAleo: + monkeypatch.setattr("aleo_bridge._calls._network_module", lambda aleo: FakeNetModule) + return FakeAleo(mappings=default_mappings()) + + +@pytest.fixture +def bridge(fake_aleo): + from aleo_bridge import Bridge + return Bridge(fake_aleo) diff --git a/bridge-sdk/tests/fakes/__init__.py b/bridge-sdk/tests/fakes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py new file mode 100644 index 0000000..23840c0 --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -0,0 +1,519 @@ +"""A duck-typed Bridge whose protocol modules record calls and return scripted results. + +Shape fidelity matters: results carry the real ``types`` dataclasses, Aleo calls +expose ``delegate_prepared / prove / submit_prepared`` exactly like ``AleoCall``, +EVM/Solana calls invoke ``on_checkpoint(receipt)`` for every intermediate +submission before returning, and ``eth``/``sol`` are properties that raise +``ConfigurationError`` when the matching ``ethereum``/``solana`` connection is +``None`` — the conventions plans 2/3 implement. + +``FakeBridge.aleo`` is ``tests.conftest.FakeAleo`` (the facade-level fake already +used by plan 1-3 tests) rather than a second, ad hoc Aleo fake: there is exactly +one Aleo fake in this test suite. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, replace +from typing import Any, Callable + +from aleo import AleoNetworkError +from aleo.facade.errors import TransactionNotFound + +from aleo_bridge.checkpoint import Checkpoint, create_checkpoint +from aleo_bridge.errors import (AttestationError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError) +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, + DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, GasQuote, + MintReceipt, PreparedTx, PrivacyReceipt, Receipt, + SolanaHyperlaneQuote, Status) + +from tests.conftest import FakeAleo as _ConftestFakeAleo +from tests.conftest import default_mappings + +ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +#: What ``PrivacyModule.unshield`` really puts into ``call.inputs``: the selected record's +#: PLAINTEXT (see ``privacy.py`` — ``select_record`` returns the plaintext string). Anything that +#: renders a built call for a human or a model has to redact it. +RECORD_PLAINTEXT = ( + "{ owner: aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n.private, " + "amount: 100000000u128.private, " + "_nonce: 5749463759923163832671233077408835222301563867853163045949890371815825289938group.public }") +EVM_ADDRESS = "0x0000000000000000000000000000000000000001" +SOL_ADDRESS = "11111111111111111111111111111111" +OUTBOUND = {"aleo/eth": "ethereum/eth", "aleo/wbtc": "ethereum/wbtc", + "aleo/usdt": "ethereum/usdt", "aleo/sol": "solana/sol"} + + +def serialized_tx(tx_id: str) -> str: + return json.dumps({"type": "execute", "id": tx_id, "fee": {}}) + + +class FakeAleoCall: + def __init__(self, fake: "FakeBridge", program_id: str, function_name: str, inputs: list[str], + tx_id: str, make_result: Callable[[str], Any]) -> None: + self.fake, self.program_id, self.function_name, self.inputs = fake, program_id, function_name, inputs + self.tx_id, self._make = tx_id, make_result + + def simulate(self, account=None): + self.fake.events.append(("simulate", self.function_name)) + return "authorized" + + def prove(self, account=None, **fee) -> PreparedTx: + self.fake.events.append(("prove", self.tx_id)) + return PreparedTx(self.tx_id, serialized_tx(self.tx_id)) + + def delegate_prepared(self, account=None, **fee) -> PreparedTx: + self.fake.events.append(("delegate_prepared", self.tx_id)) + return PreparedTx(self.tx_id, serialized_tx(self.tx_id)) + + def submit_prepared(self, prepared: PreparedTx, *, wait=True, wait_timeout=180.0): + self.fake.events.append(("submit", prepared.transaction_id, wait)) + if self.fake.submit_error is not None: + # The bytes are already checkpointed and may be on the wire: an ambiguous broadcast. + raise self.fake.submit_error + self.fake.submitted.append(prepared.serialized) + return self._make(prepared.transaction_id) + + def transact(self, account=None, **fee): + self.fake.events.append(("transact", self.tx_id)) + return self._make(self.tx_id) + + def delegate(self, account=None, **kw): + self.fake.events.append(("delegate", self.tx_id)) + return self._make(self.tx_id) + + +class FakeEvmCall: + """Mirrors ``EvmCall``/``SolCall``: every broadcast boundary — each approval and the final + transaction — is reduced to a ``Checkpoint``, handed to ``on_checkpoint`` and only then saved + to the bound store, exactly like the real calls' own channel. Without a plan (the non-plan + call form) there is nothing to reduce against, so the raw receipt is passed through instead. + """ + + def __init__(self, fake: "FakeBridge", intermediates: list[Receipt], final: Any, *, + plan: Any = None, store: Any = None, send_error: Exception | None = None) -> None: + self.fake, self.intermediates, self.final = fake, intermediates, final + self.plan, self.store = plan, store + self.send_error = send_error # raised after the intermediates: an ambiguous broadcast + + def build(self) -> list[dict]: + return [{"to": "0xrouter", "data": "0x", "value": 0}] + + def _emit(self, receipt: Receipt, on_checkpoint) -> None: + payload = receipt if self.plan is None else create_checkpoint(self.plan, receipt, DEFAULT_REGISTRY) + if on_checkpoint is not None: + on_checkpoint(payload) # the caller's channel first: the tx is already on the wire + if self.store is not None and isinstance(payload, Checkpoint): + self.store.save(payload) + + def send(self, *, wait=True, timeout_seconds=120.0, poll_seconds=1.0, on_checkpoint=None): + self.fake.events.append(("evm_send", timeout_seconds, poll_seconds)) + for receipt in self.intermediates: + self._emit(receipt, on_checkpoint) + if self.send_error is not None: + # Mirrors the real call: the transaction is armed (approvals are already checkpointed) + # but its own outcome is unknown — a single-use call that must never be retried. + raise self.send_error + self._emit(self.final.receipt, on_checkpoint) + return self.final + + +FakeSolCall = FakeEvmCall + + +@dataclass +class FakeConnection: + address: str | None + can_sign: bool = True + + +class FakeHyperlane: + def __init__(self, fake: "FakeBridge") -> None: + self.fake = fake + self.gas = GasQuote(route_id="hyperlane:aleo/eth->ethereum/eth", gas_limit=44_000, + gas_overhead=159_337, gas_price=1_000_000_000, exchange_rate=402, + payment_microcredits=8_174_147) + self.delivered: dict[str, bool] = {} + + def quote_gas_payment(self, asset) -> GasQuote: + self.fake.calls.append(("hyperlane.quote_gas_payment", asset)) + return replace(self.gas, route_id=f"hyperlane:{asset}->{OUTBOUND[asset]}") + + def transfer_remote(self, asset, recipient, *, amount=None, amount_atomic=None, + as_signer=False, gas_payment_microcredits=None) -> FakeAleoCall: + kw = dict(asset=asset, recipient=recipient, amount=amount, amount_atomic=amount_atomic, + as_signer=as_signer, gas_payment_microcredits=gas_payment_microcredits) + self.fake.calls.append(("hyperlane.transfer_remote", kw)) + route_id = f"hyperlane:{asset}->{OUTBOUND[asset]}" + program = f"hyp_warp_token_{asset.split('/')[1]}_v2.aleo" + fn = "transfer_remote_as_signer" if as_signer else "transfer_remote" + + def make(tx_id: str) -> DispatchReceipt: + receipt = Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, + protocol_state={"routeId": route_id, "sourceProgram": program, + "sourceFunction": fn}) + return DispatchReceipt(tx_id, route_id, None, amount_atomic or 0, receipt) + + return FakeAleoCall(self.fake, program, fn, ["<7 literals>"] * 7, self.fake.next_tx_id(), make) + + def is_delivered(self, message_id) -> bool: + key = message_id if isinstance(message_id, str) else "0x" + bytes(message_id).hex() + self.fake.calls.append(("hyperlane.is_delivered", key)) + return self.delivered.get(key, False) + + +class FakeXReserve: + def __init__(self, fake: "FakeBridge") -> None: + self.fake = fake + self.attestations: dict[str, Attestation] = {} # messageHash hex -> Attestation + self.delivered_nonces: set[str] = set() + self.expected_secret_nonce: str | None = None # private_mint raises when it differs + + def _route(self): + env = self.fake.environment + return ("xreserve:aleo/usdcx->ethereum/usdc" if env == "mainnet" + else "xreserve:aleo-testnet/usdcx->sepolia/usdc") + + def burn(self, recipient, *, amount=None, amount_atomic=None, mode="private", + record=None, merkle_proof=None) -> FakeAleoCall: + kw = dict(recipient=recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, + record=record, merkle_proof=merkle_proof) + self.fake.calls.append(("xreserve.burn", kw)) + route_id = self._route() + program = "shielded_usdcx_wrapper.aleo" if mode == "private" else "usdcx_bridge_v2.aleo" + fn = {"private": "private_burn", "public": "burn_public", + "public-as-signer": "burn_public_as_signer"}[mode] + + def make(tx_id: str) -> BurnReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, + protocol_state={"routeId": route_id, "burnMode": mode, + "sourceProgram": program, "sourceFunction": fn}) + return BurnReceipt(tx_id, route_id, mode, amount_atomic or 0, receipt) + + return FakeAleoCall(self.fake, program, fn, [""], self.fake.next_tx_id(), make) + + def private_mint(self, attestation: Attestation, recipient, *, secret_nonce="0scalar", + route=None) -> FakeAleoCall: + self.fake.calls.append(("xreserve.private_mint", {"recipient": recipient, + "secret_nonce": secret_nonce, + "message_hash": "0x" + attestation.message_hash.hex()})) + if self.expected_secret_nonce is not None and secret_nonce != self.expected_secret_nonce: + raise AttestationError("Private mint secret nonce and recipient do not match the attested hook data") + route_id = route.id if route is not None else "xreserve:ethereum/usdc->aleo/usdcx" + + def make(tx_id: str) -> MintReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.DESTINATION_CONFIRMING, + destination_tx_id=tx_id, protocol_state={"routeId": route_id}) + return MintReceipt(tx_id, route_id, receipt) + + return FakeAleoCall(self.fake, "shielded_usdcx_wrapper.aleo", "private_mint", + ["<5 inputs>"], self.fake.next_tx_id(), make) + + def get_attestation(self, message_hash, *, route=None) -> Attestation | None: + key = message_hash if isinstance(message_hash, str) else "0x" + bytes(message_hash).hex() + self.fake.calls.append(("xreserve.get_attestation", key.lower())) + return self.attestations.get(key.lower()) + + def is_delivered(self, nonce, *, route=None) -> bool: + key = nonce if isinstance(nonce, str) else "0x" + bytes(nonce).hex() + self.fake.calls.append(("xreserve.is_delivered", key.lower())) + return key.lower() in self.delivered_nonces + + +class FakeEth: + """Mirrors ``aleo_bridge.eth.EthModule``'s public surface for lifecycle tests. + + ``quote_transfer_remote``/``quote_deposit_usdc`` accept ``plan=`` exactly like the real + module: mutually exclusive with ``asset=``/``route=``/``sender=`` (a ``ValueError`` otherwise), + checked against ``DEFAULT_REGISTRY.version``, and the returned quote carries that same ``plan`` + object (``.plan is plan``) — ``lifecycle.quote`` is what canonicalizes the plan on the result, so + the fake does not need to rebuild one the way the real module does. + + The write side mirrors the same signatures: ``transfer_remote(asset=None, recipient=None, *, + amount=None, amount_atomic=None, plan=None)`` and ``deposit_usdc(recipient=None, *, amount=None, + amount_atomic=None, mint_mode=None, secret_nonce="0scalar", plan=None)``, with the real module's + guards — ``plan`` plus ``asset`` is a ``ValueError``, no plan and no recipient is an + ``InvalidRecipientError``, a stale plan is a ``RegistryVersionMismatchError``, and a plan whose + sender is not the connected account is a ``ConfigurationError``. + """ + + def __init__(self, fake: "FakeBridge", address: str) -> None: + self.fake, self.address = fake, address + self.approval_required = False + self.balances: dict[str, int] = {} + self.delivered: dict[str, bool] = {} + self.hook_data = bytes([0]) + b"\x00" * 64 + self.source_status_result: Receipt | None = None + self.recover_result: Receipt | None = None + self.intermediates: list[Receipt] = [] + self.send_error: Exception | None = None # raised by send() after the intermediates + + def quote_transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_atomic=None, + route=None, sender=None, plan=None): + if plan is not None: + if asset is not None or route is not None or sender is not None: + raise ValueError("Pass plan= or asset=/route=/sender=, not both") + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + self.fake.calls.append(("eth.quote_transfer_remote", {"plan": plan})) + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=plan, fees=(), amount_out=None, + recipient_bytes32=b"\x00" * 32, + native_value_atomic=plan.amount_atomic + 1000, + native_fee_atomic=1000, approval_required=self.approval_required) + self.fake.calls.append(("eth.quote_transfer_remote", dict(asset=asset, recipient=recipient, + amount_atomic=amount_atomic))) + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=None, fees=(), amount_out=None, + recipient_bytes32=b"\x00" * 32, native_value_atomic=(amount_atomic or 0) + 1000, + native_fee_atomic=1000, approval_required=self.approval_required) + + def _check_plan(self, plan) -> None: + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + if plan.sender and plan.sender.lower() != self.address.lower(): + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {self.address}") + + def transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_atomic=None, + plan=None) -> FakeEvmCall: + if plan is not None: + if asset is not None: + raise ValueError("Pass plan= or asset=, not both") + self._check_plan(plan) + self.fake.calls.append(("eth.transfer_remote", {"plan": plan})) + route_id, recipient, amount_atomic = plan.route_id, plan.recipient, plan.amount_atomic + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if asset is None: + raise ValueError("transfer_remote needs asset= or plan=") + self.fake.calls.append(("eth.transfer_remote", dict(asset=asset, recipient=recipient, + amount_atomic=amount_atomic))) + route_id = f"hyperlane:{asset}->aleo/{asset.split('/')[1]}" + tx = "0x" + "aa" * 32 + receipt = Receipt(id=tx, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx, + protocol_state={"routeId": route_id, "approvalTxIds": [], "sourceSender": self.address, + "amountAtomic": str(amount_atomic or 0)}) + return FakeEvmCall(self.fake, self.intermediates, + DispatchReceipt(tx, route_id, None, amount_atomic or 0, receipt), + plan=plan, store=self.fake.checkpoints, send_error=self.send_error) + + def quote_deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, + secret_nonce="0scalar", sender=None, route=None, plan=None): + if plan is not None: + if route is not None or sender is not None: + raise ValueError("Pass plan= or route=/sender=, not both") + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + self.fake.calls.append(("eth.quote_deposit_usdc", {"plan": plan, "secret_nonce": secret_nonce})) + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), amount_out=None, + hook_data=self.hook_data, remote_recipient_bytes32=b"\x00" * 32, + balance_atomic=10_000_000, + allowance_atomic=0 if self.approval_required else 10_000_000, + approval_required=self.approval_required, max_fee_atomic=100_000) + mint_mode = "public" if mint_mode is None else mint_mode + self.fake.calls.append(("eth.quote_deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, + mint_mode=mint_mode, secret_nonce=secret_nonce))) + return EvmXReserveQuote(kind="evm-xreserve", plan=None, fees=(), amount_out=None, hook_data=self.hook_data, + remote_recipient_bytes32=b"\x00" * 32, balance_atomic=10_000_000, + allowance_atomic=0 if self.approval_required else 10_000_000, + approval_required=self.approval_required, max_fee_atomic=100_000) + + def deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, + secret_nonce="0scalar", plan=None) -> FakeEvmCall: + if plan is not None: + self._check_plan(plan) + self.fake.calls.append(("eth.deposit_usdc", {"plan": plan, "secret_nonce": secret_nonce})) + recipient, amount_atomic = plan.recipient, plan.amount_atomic + mint_mode = plan.mint_mode if mint_mode is None else mint_mode + route_id = plan.route_id + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + mint_mode = "public" if mint_mode is None else mint_mode + self.fake.calls.append(("eth.deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, + mint_mode=mint_mode, secret_nonce=secret_nonce))) + route_id = ("xreserve:ethereum/usdc->aleo/usdcx" if self.fake.environment == "mainnet" + else "xreserve:sepolia/usdc->aleo-testnet/usdcx") + tx = "0x" + "bb" * 32 + message_hash = "0x" + "cc" * 32 + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, source_tx_id=tx, + protocol_state={"routeId": route_id, "approvalTxIds": [], "sourceSender": self.address, + "mintMode": mint_mode, "intendedRecipient": recipient, + "hookData": "0x" + self.hook_data.hex(), "nonce": "0x" + "dd" * 32, + "payload": "0x" + "ee" * 305, "messageHash": message_hash, + "bridgeProgram": "usdcx_bridge_v2.aleo"}) + return FakeEvmCall(self.fake, self.intermediates, + DepositReceipt(tx, route_id, message_hash, "0x" + "dd" * 32, receipt), + plan=plan, store=self.fake.checkpoints, send_error=self.send_error) + + def balance(self, asset) -> int: + self.fake.calls.append(("eth.balance", asset)) + return self.balances.get(asset, 0) + + def is_delivered(self, message_id) -> bool: + key = message_id if isinstance(message_id, str) else "0x" + bytes(message_id).hex() + self.fake.calls.append(("eth.is_delivered", key)) + return self.delivered.get(key, False) + + def source_status(self, plan, receipt) -> Receipt: + self.fake.calls.append(("eth.source_status", receipt.status)) + return self.source_status_result or receipt + + def recover_source(self, plan, checkpoint, *, required=False) -> Receipt: + self.fake.calls.append(("eth.recover_source", checkpoint.to_dict(), required)) + assert self.recover_result is not None, "script FakeEth.recover_result first" + return self.recover_result + + +class FakeSol: + """Mirrors ``aleo_bridge.sol.SolModule``'s public surface for lifecycle tests. + + ``quote_transfer_remote(recipient=None, *, amount=None, amount_atomic=None, sender=None, + plan=None)`` and ``transfer_remote(recipient=None, *, amount=None, amount_atomic=None, + plan=None)`` mirror the real ``SolModule`` signatures: ``plan=`` alone is enough (it supplies + recipient, amount and sender), ``plan`` together with ``sender=`` is a ``ValueError``, and + neither a plan nor a recipient is an ``InvalidRecipientError``. There is no ``asset=``/``route=`` + kwarg to conflict with, so a plan overrides recipient/amount silently — the real module's + behavior, not the Eth-like ``ValueError`` on every conflict. + + Solana addresses are base58 and therefore case-SENSITIVE: the plan-sender check compares them + exactly, unlike ``FakeEth``'s case-insensitive EVM comparison. + """ + + def __init__(self, fake: "FakeBridge", address: str) -> None: + self.fake, self.address = fake, address + self.balance_lamports = 0 + self.source_status_result: Receipt | None = None + self.intermediates: list[Receipt] = [] + self.transaction_logs_result: list[str] | None = None + self.transaction_logs_error: Exception | None = None + + def _check_plan(self, plan) -> None: + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + if plan.sender and plan.sender != self.address: # base58: compared exactly + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {self.address}") + + def quote_transfer_remote(self, recipient=None, *, amount=None, amount_atomic=None, sender=None, plan=None): + if plan is not None: + if sender is not None: + raise ValueError("Pass plan= or sender=, not both: the plan carries its own sender") + self._check_plan(plan) + self.fake.calls.append(("sol.quote_transfer_remote", {"plan": plan})) + return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=plan, fees=(), amount_out=None, + igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, + total_lamports=plan.amount_atomic + 7_914_240, + unique_message_address="uniq1111111111111111111111111111111111111111") + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + self.fake.calls.append(("sol.quote_transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) + return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=None, fees=(), amount_out=None, + igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, + total_lamports=(amount_atomic or 0) + 7_914_240, + unique_message_address="uniq1111111111111111111111111111111111111111") + + def transfer_remote(self, recipient=None, *, amount=None, amount_atomic=None, plan=None) -> FakeSolCall: + if plan is not None: + self._check_plan(plan) + self.fake.calls.append(("sol.transfer_remote", {"plan": plan})) + recipient, amount_atomic = plan.recipient, plan.amount_atomic + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + self.fake.calls.append(("sol.transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) + route_id = "hyperlane:solana/sol->aleo/sol" + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=sig, + protocol_state={"routeId": route_id, "signature": sig, "blockhash": "recent", + "lastValidBlockHeight": "123456789"}) + return FakeSolCall(self.fake, self.intermediates, + DispatchReceipt(sig, route_id, None, amount_atomic or 0, receipt), + plan=plan, store=self.fake.checkpoints) + + def balance(self) -> int: + self.fake.calls.append(("sol.balance",)) + return self.balance_lamports + + def source_status(self, plan, receipt) -> Receipt: + self.fake.calls.append(("sol.source_status", receipt.status)) + return self.source_status_result or receipt + + def _transaction_logs(self, signature) -> list[str] | None: + self.fake.calls.append(("sol._transaction_logs", signature)) + if self.transaction_logs_error is not None: + raise self.transaction_logs_error + return self.transaction_logs_result + + +class FakeBridge: + """Duck-typed stand-in for ``aleo_bridge.client.Bridge`` (no network, no extras).""" + + def __init__(self, *, environment="mainnet", ethereum=True, solana=False, checkpoints=None) -> None: + self.registry = DEFAULT_REGISTRY + self.environment = self.network = environment + self.checkpoints = checkpoints + self.events: list[tuple] = [] # ordered side effects (prove/submit/checkpoint...) + self.calls: list[tuple] = [] # module method calls with kwargs + self.submitted: list[str] = [] + self.submit_error: Exception | None = None # raised by submit_prepared, after the checkpoint + self._tx = 0 + self.aleo = _ConftestFakeAleo(mappings=default_mappings(), network_name=environment) + self.hyperlane = FakeHyperlane(self) + self.xreserve = FakeXReserve(self) + self.ethereum = FakeConnection(EVM_ADDRESS) if ethereum else None + self._eth = FakeEth(self, EVM_ADDRESS) if ethereum else None + self.solana = FakeConnection(SOL_ADDRESS) if solana else None + self._sol = FakeSol(self, SOL_ADDRESS) if solana else None + self.public_balances: dict[str, int] = {} + + @property + def eth(self) -> FakeEth: + """Mirrors the real ``Bridge.eth`` property: ``ConfigurationError`` when ``ethereum`` is None.""" + if self._eth is None: + raise ConfigurationError("Pass ethereum=Ethereum(...) to Bridge(...) or set ETHEREUM_RPC_URL") + return self._eth + + @property + def sol(self) -> FakeSol: + """Mirrors the real ``Bridge.sol`` property: ``ConfigurationError`` when ``solana`` is None.""" + if self._sol is None: + raise ConfigurationError( + "Solana is not configured: pass solana=Solana(rpc_url, private_key=...) or a solana-py Client to Bridge(), " + "or set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) for Bridge.from_env()") + return self._sol + + def next_tx_id(self) -> str: + self._tx += 1 + return f"at1fake{self._tx}" + + def aleo_address(self) -> str: + return ALEO_RECIPIENT + + # Plan-1 surface the agent tools touch + def shield(self, asset, *, amount=None, amount_atomic=None, recipient=None) -> FakeAleoCall: + self.calls.append(("shield", dict(asset=asset, amount=amount, amount_atomic=amount_atomic))) + return FakeAleoCall(self, "arc20_eth.aleo", "shield", [f"{amount_atomic}u128"], self.next_tx_id(), + lambda tx: PrivacyReceipt(tx, asset, str(amount or amount_atomic), amount_atomic or 0, "shield")) + + def unshield(self, asset, *, amount=None, amount_atomic=None, record=None, merkle_proof=None, recipient=None): + self.calls.append(("unshield", dict(asset=asset, amount=amount, amount_atomic=amount_atomic))) + return FakeAleoCall(self, "arc20_eth.aleo", "unshield", [RECORD_PLAINTEXT, f"{amount_atomic}u128"], self.next_tx_id(), + lambda tx: PrivacyReceipt(tx, asset, str(amount or amount_atomic), amount_atomic or 0, "unshield")) + + def status(self) -> BridgeStatus: + chains = [ChainStatus("aleo" if self.environment == "mainnet" else "aleo-testnet", ALEO_RECIPIENT, True, + dict(self.public_balances))] + if self.ethereum is not None: + chains.append(ChainStatus("ethereum", self.ethereum.address, True, dict(self.eth.balances))) + if self.solana is not None: + chains.append(ChainStatus("solana", self.solana.address, True, {"solana/sol": self.sol.balance_lamports})) + from aleo_bridge.lifecycle import recover + pending = [recover(self, cp) for cp in self.checkpoints.list()] if self.checkpoints else [] + return BridgeStatus(self.environment, self.registry.version, chains, pending) diff --git a/bridge-sdk/tests/fakes/fake_solana.py b/bridge-sdk/tests/fakes/fake_solana.py new file mode 100644 index 0000000..74c2799 --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_solana.py @@ -0,0 +1,151 @@ +"""Fake solana-py ``Client`` for SolModule tests. + +Mirrors solana-py 0.40's shapes: every method returns an object with ``.value``; +``get_latest_blockhash().value`` has ``blockhash``/``last_valid_block_height``; +``get_account_info().value`` is ``None`` or has ``.data: bytes``; signature statuses are a +list with ``err``/``confirmation_status``; ``get_transaction().value.transaction.meta.log_messages``. +""" +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +from solders.hash import Hash +from solders.signature import Signature +from solders.transaction import VersionedTransaction + +from aleo_bridge.registry import DEFAULT_REGISTRY +from tests.fakes.sealevel_fixtures import ( + DISPATCHED_MESSAGE_RENT_LAMPORTS, + FEE_PAYER_RENT_LAMPORTS, + GAS_PAYMENT_RENT_LAMPORTS, + IGP, + NETWORK_FEE_LAMPORTS, + TRANSFER, + WARP_PROGRAM_ADDRESS, + igp_account_data, +) + +# Any 32-byte base58 string is a valid blockhash for compile/sign purposes (veil uses the warp program id). +BLOCKHASH = Hash.from_string(WARP_PROGRAM_ADDRESS) +LAST_VALID_BLOCK_HEIGHT = 100 +RENTS = {141: GAS_PAYMENT_RENT_LAMPORTS, 194: DISPATCHED_MESSAGE_RENT_LAMPORTS, 0: FEE_PAYER_RENT_LAMPORTS} +STUB_SIGNATURE = Signature.from_bytes(bytes([7]) * 64) + + +class _Resp: + def __init__(self, value: Any) -> None: + self.value = value + + +@dataclass +class FakeSignatureStatus: + err: Any = None + confirmation_status: str | None = "confirmed" + + +@dataclass +class _Blockhash: + blockhash: Hash + last_valid_block_height: int + + +@dataclass +class _Account: + data: bytes + + +class FakeSolanaClient: + """``statuses`` is consumed one entry per ``get_signature_statuses`` call (the last entry repeats); + an entry may be ``None`` (unknown signature), a ``FakeSignatureStatus``, or an ``Exception`` to raise.""" + + def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] | None = None, + fee: int = NETWORK_FEE_LAMPORTS, rents: dict[int, int] | None = None, + statuses: list[Any] | None = None, blockhash_valid: Any = True, + logs: list[str] | None = None, no_logs: bool = False, + signature: Signature | None = None, get_transaction_error: Exception | None = None, + send_error: Exception | None = None) -> None: + self.balance = balance + self.accounts = {IGP["address"]: igp_account_data()} if accounts is None else accounts + self.fee = fee + self.rents = RENTS if rents is None else rents + self.statuses = list(statuses) if statuses is not None else [FakeSignatureStatus()] + self.blockhash_valid = blockhash_valid + # logs=None → the recorded mainnet logs; logs=[] → confirmed but no dispatch line; no_logs → transaction not found + self.logs = None if no_logs else (list(TRANSFER["logMessages"]) if logs is None else list(logs)) + # None → echo the transaction's own fee-payer signature, as a real node does. A Signature here + # makes the node answer with a DIFFERENT id than the one the client signed (the mismatch case). + self.signature = signature + self.get_transaction_error = get_transaction_error # raised by get_transaction (RPC/decode failure) + self.send_error = send_error # raised by send_raw_transaction (response lost) + self.calls: list[str] = [] + self.fee_messages: list[Any] = [] + self.sent: list[bytes] = [] + self.sent_opts: list[Any] = [] + self.status_calls: list[bool] = [] + self.transaction_calls: list[tuple[Any, Any]] = [] + + def get_latest_blockhash(self, commitment=None): + self.calls.append("get_latest_blockhash") + return _Resp(_Blockhash(BLOCKHASH, LAST_VALID_BLOCK_HEIGHT)) + + def get_balance(self, pubkey, commitment=None): + self.calls.append("get_balance") + return _Resp(self.balance) + + def get_account_info(self, pubkey, commitment=None, encoding="base64", data_slice=None): + self.calls.append("get_account_info") + data = self.accounts.get(str(pubkey)) + return _Resp(None if data is None else _Account(data)) + + def get_fee_for_message(self, message, commitment=None): + self.calls.append("get_fee_for_message") + self.fee_messages.append(message) + return _Resp(self.fee) + + def get_minimum_balance_for_rent_exemption(self, usize, commitment=None): + self.calls.append("get_minimum_balance_for_rent_exemption") + return _Resp(self.rents[usize]) + + def send_raw_transaction(self, txn, opts=None): + self.calls.append("send_raw_transaction") + raw = bytes(txn) + self.sent.append(raw) + self.sent_opts.append(opts) + if self.send_error is not None: + raise self.send_error + return _Resp(self.signature if self.signature is not None else VersionedTransaction.from_bytes(raw).signatures[0]) + + def sent_signature(self, index: int = 0) -> str: + """Fee-payer signature of the index-th broadcast transaction — the id the node echoed back.""" + return str(VersionedTransaction.from_bytes(self.sent[index]).signatures[0]) + + def get_signature_statuses(self, signatures, search_transaction_history=False): + self.calls.append("get_signature_statuses") + self.status_calls.append(search_transaction_history) + item = self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0] + if isinstance(item, Exception): + raise item + return _Resp([item]) + + def is_blockhash_valid(self, blockhash, commitment=None): + self.calls.append("is_blockhash_valid") + if isinstance(self.blockhash_valid, Exception): + raise self.blockhash_valid + return _Resp(self.blockhash_valid) + + def get_transaction(self, tx_sig, encoding="json", commitment=None, max_supported_transaction_version=None): + self.calls.append("get_transaction") + self.transaction_calls.append((commitment, max_supported_transaction_version)) + if self.get_transaction_error is not None: + raise self.get_transaction_error + if self.logs is None: + return _Resp(None) + meta = SimpleNamespace(log_messages=list(self.logs)) + return _Resp(SimpleNamespace(transaction=SimpleNamespace(meta=meta))) + + +def stub_bridge(environment: str = "mainnet", checkpoints: Any = None) -> SimpleNamespace: + """What SolModule needs from a Bridge without constructing one: registry, environment, checkpoint store.""" + return SimpleNamespace(registry=DEFAULT_REGISTRY, environment=environment, checkpoints=checkpoints) diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py new file mode 100644 index 0000000..5e93b5a --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -0,0 +1,332 @@ +"""A hand-rolled JSON-RPC provider behind a real ``web3.Web3``. + +The real web3 contract/ABI/event/signing stack runs unchanged; only the +transport is fake, so tests exercise the exact calldata, event decoding and +raw-transaction signing that production uses. State is plain dicts the test +mutates directly. + +The Aleo-side fake facade lives in ``tests/conftest.py`` (``FakeAleo``, +``bridge``/``fake_aleo`` fixtures) — this module holds only web3-specific +fakes so there is exactly one Aleo fake in the suite. +""" +from __future__ import annotations + +from typing import Any, Callable + +from eth_abi import decode, encode +from eth_account import Account +from eth_utils import keccak, to_checksum_address +from hexbytes import HexBytes +from web3 import Web3 +from web3.providers import BaseProvider + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +BLOCK_HASH = "0x" + "cd" * 32 +SELECTORS = { + keccak(text="balanceOf(address)")[:4]: "balanceOf", + keccak(text="allowance(address,address)")[:4]: "allowance", + keccak(text="quoteTransferRemote(uint32,bytes32,uint256)")[:4]: "quoteTransferRemote", + keccak(text="delivered(bytes32)")[:4]: "delivered", +} +TOPIC_DISPATCH_ID = "0x" + keccak(text="DispatchId(bytes32)").hex() +TOPIC_SENT_TRANSFER_REMOTE = "0x" + keccak(text="SentTransferRemote(uint32,bytes32,uint256)").hex() +TOPIC_DEPOSITED_TO_REMOTE = "0x" + keccak( + text="DepositedToRemote(address,uint256,address,bytes32,uint32,bytes32,uint256,bytes)" +).hex() + + +def tx_hash_for(n: int) -> str: + """Deterministic hash of the n-th (1-based) transaction the fake accepted through ``eth_sendTransaction``. + + Raw (locally signed) transactions get their REAL hash — ``keccak(raw)`` — because eth.py now + checks the node's echoed hash against the one it computed locally, exactly as a real node would + answer. Use ``provider.hash_at(n)`` to read a hash back after the send, and the ``*_nth`` knobs + to arm ``pending``/``reverted``/``receipt_delay`` for a transaction not yet broadcast. + """ + return "0x" + keccak(text=f"fake-tx-{n}").hex() + + +def _hex(n: int) -> str: + return hex(n) + + +def event_log(address: str, topics: list[str], data: str, *, log_index: int, tx_hash: str, + block_number: int = 0x11) -> dict: + return {"address": to_checksum_address(address), "topics": topics, "data": data, + "logIndex": _hex(log_index), "blockNumber": _hex(block_number), "transactionHash": tx_hash, + "transactionIndex": "0x0", "blockHash": BLOCK_HASH, "removed": False} + + +def dispatch_id_log(mailbox: str, message_id: bytes, *, tx_hash: str, log_index: int = 5, block_number: int = 0x11) -> dict: + return event_log(mailbox, [TOPIC_DISPATCH_ID, "0x" + message_id.hex()], "0x", + log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def sent_transfer_remote_log(router: str, *, destination: int, recipient32: bytes, amount: int, tx_hash: str, + log_index: int = 4, block_number: int = 0x65) -> dict: + topics = [TOPIC_SENT_TRANSFER_REMOTE, "0x" + encode(["uint32"], [destination]).hex(), "0x" + recipient32.hex()] + return event_log(router, topics, "0x" + encode(["uint256"], [amount]).hex(), + log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def deposited_log(xreserve: str, *, local_token: str, depositor: str, remote_recipient32: bytes, value: int, + remote_domain: int, remote_token32: bytes, max_fee: int, hook_data: bytes, tx_hash: str, + log_index: int = 3, block_number: int = 0x65) -> dict: + topics = [TOPIC_DEPOSITED_TO_REMOTE, "0x" + encode(["address"], [local_token]).hex(), + "0x" + encode(["address"], [depositor]).hex(), "0x" + remote_recipient32.hex()] + data = encode(["uint256", "uint32", "bytes32", "uint256", "bytes"], + [value, remote_domain, remote_token32, max_fee, hook_data]) + return event_log(xreserve, topics, "0x" + data.hex(), log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def _decode_raw(raw: bytes) -> dict: + """Signed raw tx → {to, value, data, from, nonce, gas, ...fee fields}. Typed (0x02) and legacy envelopes. + + Keeps whichever fee fields the sender actually filled in (``gasPrice`` for a + legacy/type-0 envelope, ``maxFeePerGas``/``maxPriorityFeePerGas`` for a type-2 + one) so tests can assert on the exact values ``eth.py``'s fee-filling logic + computed, not just that *some* transaction was sent. + """ + from eth_account.typed_transactions import TypedTransaction + + sender = Account.recover_transaction(raw) + if raw[0] <= 0x7F: + fields = TypedTransaction.from_bytes(HexBytes(raw)).as_dict() + else: + import rlp + from eth_account._utils.legacy_transactions import Transaction + + fields = rlp.decode(raw, Transaction).as_dict() + data = fields.get("data", b"") + data_hex = data if isinstance(data, str) else "0x" + bytes(data).hex() + tx = {"to": to_checksum_address(fields["to"]), "value": int(fields.get("value", 0)), "data": data_hex, "from": sender} + for key in ("nonce", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"): + if fields.get(key) is not None: + tx[key] = int(fields[key]) + return tx + + +class FakeRpcProvider(BaseProvider): + """State: balances, allowances, router quotes, delivered ids, sent txs, receipts, history logs.""" + + def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = None, + token_balances: dict[tuple[str, str], int] | None = None, + allowances: dict[tuple[str, str, str], int] | None = None, + quotes: dict[str, list[tuple[str, int]]] | None = None, + delivered: set[str] | None = None, legacy: bool = False) -> None: + super().__init__() + self.chain_id = chain_id + self.legacy = legacy # True: eth_getBlockByNumber omits baseFeePerGas + self.eth_balances = {to_checksum_address(k): v for k, v in (eth_balances or {}).items()} + self.token_balances = {(to_checksum_address(t), to_checksum_address(o)): v + for (t, o), v in (token_balances or {}).items()} + self.allowances = {(to_checksum_address(t), to_checksum_address(o), to_checksum_address(s)): v + for (t, o, s), v in (allowances or {}).items()} + self.quotes = {to_checksum_address(r): q for r, q in (quotes or {}).items()} + self.delivered = {d.lower() for d in (delivered or set())} + self.sent: list[dict] = [] # {to, value, data, from, hash} in send order + self.pending: set[str] = set() # hashes whose receipt stays None + self.reverted: set[str] = set() # hashes whose receipt has status 0 + self.receipt_delay: dict[str, int] = {} # hash -> remaining polls that return None before mined + # A locally signed transaction's hash is only known once it is signed, so these arm the three + # knobs above by 1-based send order instead; _accept translates them the moment it accepts. + self.pending_nth: set[int] = set() + self.reverted_nth: set[int] = set() + self.receipt_delay_nth: dict[int, int] = {} + self.send_errors: dict[int, str] = {} # 1-based send -> JSON-RPC error (the response is lost) + self.echo_hashes: dict[int, str] = {} # 1-based send -> hash to echo INSTEAD of the real one + self.receipt_poll_counts: dict[str, int] = {} # hash -> eth_getTransactionReceipt calls seen for it + self.receipt_logs: Callable[[dict], list[dict]] = lambda tx: [] # logs for a sent tx's receipt + self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/from/toBlock) + self.log_filters: list[dict] = [] # raw eth_getLogs filter params, in call order + self.log_scan_errors: dict[int, str] = {} # 1-based eth_getLogs call -> JSON-RPC error message + self.transactions: dict[str, dict] = {} # extra eth_getTransactionByHash answers + self.receipts: dict[str, dict] = {} # extra eth_getTransactionReceipt answers + self.block_number = 0x10 + self.methods: list[str] = [] + + def _ok(self, result: Any) -> dict: + return {"jsonrpc": "2.0", "id": 1, "result": result} + + def _block_tag(self, value: Any, default: int) -> int: + """A JSON-RPC block tag as an int; ``None``/``"latest"`` fall back to *default*.""" + if value is None or value in ("latest", "pending", "safe", "finalized"): + return default + return int(value, 16) if isinstance(value, str) else int(value) + + def _observe_block(self, block_number: int) -> None: + """Keep the head at least as high as any block the test has placed a receipt or tx in, so a + bounded (``fromBlock``..``eth_blockNumber``) log scan can actually reach that history.""" + self.block_number = max(self.block_number, block_number) + + def add_receipt(self, tx_hash: str, *, status: int = 1, logs: list[dict] | None = None, block_number: int = 0x65, + sender: str = ZERO_ADDRESS, to: str = ZERO_ADDRESS) -> None: + """Serve a receipt for a hash the fake never accepted itself (recovery / status tests).""" + self._observe_block(block_number) + self.receipts[tx_hash] = { + "transactionHash": tx_hash, "status": _hex(status), "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, + "transactionIndex": "0x0", "from": to_checksum_address(sender), "to": to_checksum_address(to), + "cumulativeGasUsed": "0x1", "gasUsed": "0x1", "effectiveGasPrice": "0x1", "type": "0x2", + "contractAddress": None, "logsBloom": "0x" + "00" * 256, "logs": logs or []} + + def add_transaction(self, tx_hash: str, *, sender: str, to: str, block_number: int = 0x65) -> None: + """Serve eth_getTransactionByHash for a hash the fake never accepted itself.""" + self._observe_block(block_number) + self.transactions[tx_hash] = { + "hash": tx_hash, "from": to_checksum_address(sender), "to": to_checksum_address(to), "input": "0x", "value": "0x0", + "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, "nonce": "0x0", "gas": "0x1", "gasPrice": "0x1", + "transactionIndex": "0x0", "type": "0x2", "chainId": _hex(self.chain_id), "v": "0x0", "r": "0x0", "s": "0x0"} + + def make_request(self, method: str, params: Any) -> dict: + self.methods.append(method) + if method == "eth_chainId": + return self._ok(_hex(self.chain_id)) + if method == "eth_blockNumber": + return self._ok(_hex(self.block_number)) + if method == "eth_gasPrice": + return self._ok(_hex(10**9)) + if method == "eth_maxPriorityFeePerGas": + return self._ok(_hex(10**8)) + if method == "eth_feeHistory": + return self._ok({"baseFeePerGas": [_hex(10**9)] * 2, "gasUsedRatio": [0.5], + "oldestBlock": "0x1", "reward": [[_hex(10**8)]]}) + if method == "eth_getBlockByNumber": + block = {"number": _hex(self.block_number), "gasLimit": _hex(30_000_000), + "gasUsed": "0x0", "timestamp": "0x0", "hash": "0x" + "ab" * 32, "parentHash": "0x" + "00" * 32, + "transactions": [], "difficulty": "0x0", "extraData": "0x", "logsBloom": "0x" + "00" * 256, + "miner": ZERO_ADDRESS, "mixHash": "0x" + "00" * 32, "nonce": "0x0000000000000000", + "receiptsRoot": "0x" + "00" * 32, "sha3Uncles": "0x" + "00" * 32, "size": "0x1", + "stateRoot": "0x" + "00" * 32, "totalDifficulty": "0x0", + "transactionsRoot": "0x" + "00" * 32, "uncles": []} + if not self.legacy: + block["baseFeePerGas"] = _hex(10**9) + return self._ok(block) + if method == "eth_getTransactionCount": + return self._ok(_hex(len(self.sent))) + if method == "eth_estimateGas": + return self._ok(_hex(150_000)) + if method == "eth_getBalance": + return self._ok(_hex(self.eth_balances.get(to_checksum_address(params[0]), 0))) + if method == "eth_call": + return self._ok(self._call(params[0])) + if method == "eth_sendRawTransaction": + raw = bytes.fromhex(params[0][2:]) + message = self.send_errors.get(len(self.sent) + 1) + if message is not None: # the node took it, the answer never came back + return {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": message}} + return self._ok(self._accept(_decode_raw(raw), raw=raw)) + if method == "eth_sendTransaction": + p = params[0] + raw_value = p.get("value", 0) + value = int(raw_value, 16) if isinstance(raw_value, str) else int(raw_value) + return self._ok(self._accept({"to": to_checksum_address(p["to"]), "value": value, + "data": p.get("data", "0x"), "from": to_checksum_address(p["from"])})) + if method == "eth_getTransactionReceipt": + h = params[0] + self.receipt_poll_counts[h] = self.receipt_poll_counts.get(h, 0) + 1 + return self._ok(self._receipt(h)) + if method == "eth_getLogs": + f = params[0] + self.log_filters.append(f) + message = self.log_scan_errors.get(len(self.log_filters)) + if message is not None: + return {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": message}} + addr = f.get("address") + addrs = {to_checksum_address(a) for a in (addr if isinstance(addr, list) else [addr])} if addr else None + from_block = self._block_tag(f.get("fromBlock"), 0) + to_block = self._block_tag(f.get("toBlock"), self.block_number) + return self._ok([log for log in self.history_logs + if (addrs is None or log["address"] in addrs) + and from_block <= int(log["blockNumber"], 16) <= to_block]) + if method == "eth_getTransactionByHash": + h = params[0] + if h in self.transactions: + return self._ok(self.transactions[h]) + sent = next((t for t in self.sent if t["hash"] == h), None) + if sent is None: + return self._ok(None) + return self._ok({"hash": h, "from": sent["from"], "to": sent["to"], "input": sent["data"], + "value": _hex(sent["value"]), "blockNumber": _hex(self.block_number + 1), + "blockHash": BLOCK_HASH, "nonce": "0x0", "gas": "0x1", "gasPrice": "0x1", + "transactionIndex": "0x0", "type": "0x2", "chainId": _hex(self.chain_id), + "v": "0x0", "r": "0x0", "s": "0x0"}) + raise NotImplementedError(method) + + def _accept(self, tx: dict, raw: bytes | None = None) -> str: + self.sent.append(tx) + n = len(self.sent) + tx["hash"] = ("0x" + keccak(raw).hex()) if raw is not None else tx_hash_for(n) + if n in self.pending_nth: + self.pending.add(tx["hash"]) + if n in self.reverted_nth: + self.reverted.add(tx["hash"]) + if n in self.receipt_delay_nth: + self.receipt_delay[tx["hash"]] = self.receipt_delay_nth[n] + return self.echo_hashes.get(n, tx["hash"]) + + def hash_at(self, n: int) -> str: + """Hash of the n-th (1-based) accepted transaction — the real one for a locally signed send.""" + return self.sent[n - 1]["hash"] + + def _receipt(self, h: str) -> dict | None: + if h in self.pending: + return None + delay = self.receipt_delay.get(h, 0) + if delay > 0: + self.receipt_delay[h] = delay - 1 + return None + if h in self.receipts: + return self.receipts[h] + sent = next((t for t in self.sent if t["hash"] == h), None) + if sent is None: + return None + return {"transactionHash": h, "status": "0x0" if h in self.reverted else "0x1", + "blockNumber": _hex(self.block_number + 1), "blockHash": BLOCK_HASH, "transactionIndex": "0x0", + "from": sent["from"], "to": sent["to"], "cumulativeGasUsed": "0x1", "gasUsed": "0x1", + "effectiveGasPrice": "0x1", "type": "0x2", "contractAddress": None, + "logsBloom": "0x" + "00" * 256, "logs": self.receipt_logs(sent)} + + def _call(self, call: dict) -> str: + to = to_checksum_address(call["to"]) + data = bytes.fromhex(call["data"][2:]) + name = SELECTORS.get(data[:4]) + args = data[4:] + if name == "balanceOf": + (owner,) = decode(["address"], args) + return "0x" + encode(["uint256"], [self.token_balances.get((to, to_checksum_address(owner)), 0)]).hex() + if name == "allowance": + owner, spender = decode(["address", "address"], args) + key = (to, to_checksum_address(owner), to_checksum_address(spender)) + return "0x" + encode(["uint256"], [self.allowances.get(key, 0)]).hex() + if name == "quoteTransferRemote": + return "0x" + encode(["(address,uint256)[]"], [self.quotes.get(to, [])]).hex() + if name == "delivered": + (message_id,) = decode(["bytes32"], args) + return "0x" + encode(["bool"], [("0x" + message_id.hex()) in self.delivered]).hex() + raise NotImplementedError(f"eth_call selector {data[:4].hex()} to {to}") + + +def fake_web3(**config: Any) -> Web3: + """A real ``Web3`` over ``FakeRpcProvider``; reach the state through ``w3.provider``.""" + return Web3(FakeRpcProvider(**config)) + + +def make_bridge(*, ethereum: Any = None, environment: str | None = None, checkpoints: Any = None, + **aleo_kwargs: Any) -> Any: + """A ``Bridge`` over a fresh ``FakeAleo`` (mainnet unless *environment* says otherwise), wired + with *ethereum* so ``bridge.eth`` works. + + ``checkpoints`` forwards to ``Bridge(..., checkpoints=...)`` (a ``CheckpointStore``, e.g. + ``FileCheckpointStore``); it is never a ``FakeAleo`` constructor argument. + + Kept here (rather than in ``tests/conftest.py``) so ``eth.py`` tests can import one fixture + factory alongside ``fake_web3`` without pulling in pytest fixtures. + """ + from aleo_bridge import Bridge + + from tests.conftest import FakeAleo, default_mappings + + aleo_kwargs.setdefault("mappings", default_mappings()) + if environment is not None: + aleo_kwargs.setdefault("network_name", environment) + return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum, environment=environment, checkpoints=checkpoints) diff --git a/bridge-sdk/tests/fakes/sealevel_fixtures.py b/bridge-sdk/tests/fakes/sealevel_fixtures.py new file mode 100644 index 0000000..3d02008 --- /dev/null +++ b/bridge-sdk/tests/fakes/sealevel_fixtures.py @@ -0,0 +1,52 @@ +"""Golden fixtures from veil's test/fixtures (mainnet TransferRemote + inner IGP account). + +Pure: no solders import, so the _sealevel layout tests run on an Aleo-only install. +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +TRANSFER: dict = json.loads((FIXTURES / "sealevel-transfer-remote.json").read_text()) +IGP: dict = json.loads((FIXTURES / "sealevel-igp-account.json").read_text()) + +WARP_PROGRAM_ADDRESS = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" +ALEO_MAINNET_DOMAIN = 1634493807 +DESTINATION_GAS_AMOUNT = 464_000 +EXPECTED_IGP_PAYMENT_LAMPORTS = 2_900_000 +NETWORK_FEE_LAMPORTS = 10_000 +GAS_PAYMENT_RENT_LAMPORTS = 1_872_240 +DISPATCHED_MESSAGE_RENT_LAMPORTS = 2_241_120 +FEE_PAYER_RENT_LAMPORTS = 890_880 +EXPECTED_MESSAGE_ID = "0xffe0409d00c184769b4dfa2a1eaac5a0a79bfe52458a38e1d9a71a9e5c677805" + + +def igp_account_data() -> bytes: + return base64.b64decode(IGP["dataBase64"]) + + +def metadata_from_fixture(*, overhead: bool = True) -> dict[str, str | int | bool]: + """Route metadata (veil camelCase keys) read off the fixture's ordered account list (SEALEVEL_NOTES §2).""" + accounts = TRANSFER["accounts"] + metadata: dict[str, str | int | bool] = { + "warpProgramAddress": WARP_PROGRAM_ADDRESS, + "tokenPda": accounts[2]["address"], + "nativeCollateralPda": accounts[15]["address"], + "dispatchAuthorityPda": accounts[5]["address"], + "mailboxProgramAddress": accounts[3]["address"], + "mailboxOutboxPda": accounts[4]["address"], + "igpProgramAddress": accounts[9]["address"], + "igpProgramDataPda": accounts[10]["address"], + "igpAccount": accounts[13]["address"], + "splNoopProgramAddress": accounts[1]["address"], + "destinationDomain": ALEO_MAINNET_DOMAIN, + "destinationGasAmount": str(DESTINATION_GAS_AMOUNT), + "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", + "solanaReviewedAt": "2026-08-28T00:00:00Z", + "solanaConfigSource": "hyperlane-registry@418056e2:deployments/warp_routes/SOL/aleo-config.yaml", + } + if overhead: + metadata["igpOverheadAccount"] = accounts[12]["address"] + return metadata diff --git a/bridge-sdk/tests/fixtures/sealevel-igp-account.json b/bridge-sdk/tests/fixtures/sealevel-igp-account.json new file mode 100644 index 0000000..d0c1ecf --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-igp-account.json @@ -0,0 +1,5 @@ +{ + "address": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "dataBase64": "AUlHUF9fX19f/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYexQI6tUiqLpNeDGtJzI0pBygIJxeztkEnRaP31PGDzh7FAjq1SKouk14Ma0nMjSkHKAgnF7O2QSdFo/fU8YPMqAAAAAQAAAAAuJ3SEEsbS9RMAAAAAAAAAb1OZDAAAAAAAAAAAAAAAABIKAAAAAC4ndIQSxtL1EwAAAAAAAABm4j8FAAAAAAAAAAAAAAAAEjgAAAAArra/JT0xF78GAAAAAAAAAFE/ID4AAAAAAAAAAAAAAAASggAAAAAuJ3SEEsbS9RMAAAAAAAAAZuI/BQAAAAAAAAAAAAAAABKJAAAAAFDOP1FrNzkAAAAAAAAAAAD3+RHS1AEAAAAAAAAAAAAAEo8AAAAA/tlRMWbrDwAAAAAAAAAAAFiBzfhTGgAAAAAAAAAAAAASrQAAAAD87P+Pgp/tAgAAAAAAAAAASRyEIo8AAAAAAAAAAAAAABJxAQAAAHrxgJdMAQAAAAAAAAAAAAAMf+pD85tCAQAAAAAAAAAAEuABAAAALid0hBLG0vUTAAAAAAAAAA6L/xQAAAAAAAAAAAAAAAASxAMAAACWmfc66Ofk3gIAAAAAAAAAAOQLVAIAAAAAAAAAAAAAABLnAwAAAN3ZVj/lDk/RAAAAAAAAAABRSqAAAgAAAAAAAAAAAAAAErUKAAAALid0hBLG0vUTAAAAAAAAAK3y3QsAAAAAAAAAAAAAAAASEhAAAADrjW+d/ma059sCAAAAAAAAkJmSAAAAAAAAAAAAAAAAABLSFAAAAC4ndIQSxtL1EwAAAAAAAAAOi/8UAAAAAAAAAAAAAAAAEvMdAAAAGpTTD9tAAAAAAAAAAAAAAKdz5vFldgYAAAAAAAAAAAASBSEAAAAuJ3SEEsbS9RMAAAAAAAAAZuI/BQAAAAAAAAAAAAAAABIRJgAAAE34OyEArk4AAAAAAAAAAABR4Km4UwUAAAAAAAAAAAAAEiVeAAAAmcZL5bU/AgAAAAAAAAAAAEfUBJxfugAAAAAAAAAAAAASE2MAAAAuJ3SEEsbS9RMAAAAAAAAADov/FAAAAAAAAAAAAAAAABJzgQAAAFPLhMCJpWgAAAAAAAAAAACDQxZUAQQAAAAAAAAAAAAAEouGAAAALid0hBLG0vUTAAAAAAAAAA6L/xQAAAAAAAAAAAAAAAASsZcAAADZmVSdxSUWAAAAAAAAAAAA1RsYp+wSAAAAAAAAAAAAABLjoQAAAPzs/4+Cn+0CAAAAAAAAAABJHIQijwAAAAAAAAAAAAAAErGkAAAALid0hBLG0vUTAAAAAAAAADb+cgkAAAAAAAAAAAAAAAASaqgAAACzdyh4RQBqEwAAAAAAAAAAuFDGlhUAAAAAAAAAAAAAABK4wwAAAPdJMvJaBAQAAAAAAAAAAAD660lY9zoAAAAAAAAAAAAAEi7LAAAA0oEZOMajAAAAAAAAAAAAANixzhcnjwIAAAAAAAAAAAAS8d4AAAAuJ3SEEsbS9RMAAAAAAAAADov/FAAAAAAAAAAAAAAAABII5wAAAC4ndIQSxtL1EwAAAAAAAAAOi/8UAAAAAAAAAAAAAAAAEmCsAgAAnKOeGxC+AwAAAAAAAAAAAFktF27+bwAAAAAAAAAAAAASK1wJAACrKd/7z2QDAAAAAAAAAAAATAUuqH97AAAAAAAAAAAAABLSZwsAAC4ndIQSxtL1EwAAAAAAAACWxgwBAAAAAAAAAAAAAAAAEn8V/AIAodtmGJSxrQAAAAAAAAAAAD8GAAAAAAAAAAAAAAAAAAAJHoRlFQB3Lul3g54ZAAAAAAAAAAAAvluDugYAAAAAAAAAAAAAABJ7gToeAADAKfc9VAUAAAAAAAAAAACDywAAAAAAAAAAAAAAAAAACTLPox4A/Oz/j4Kf7QIAAAAAAAAAAGho3joAAAAAAAAAAAAAAAAS3FNmKwAFPZ1qitLsAAAAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAAZFTEVDANFEje1xBwAAAAAAAAAAAACoFAAAAAAAAAAAAAAAAAAABr2Q+VMA8TQm+7zPggAAAAAAAAAAAIQdAAAAAAAAAAAAAAAAAAAJXGpkYQA0fpEe/NAAAAAAAAAAAAAAx/twl6MDAAAAAAAAAAAAABJvZWxhAGCoIAWvAAAAAAAAAAAAAADhRAEAAAAAAAAAAAAAAAAABnhsb3MAe7XqTBcGAAAAAAAAAAAAAJUtAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "capturedAtSlot": 442410490 +} diff --git a/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json b/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json new file mode 100644 index 0000000..37dbfa1 --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json @@ -0,0 +1,122 @@ +{ + "signature": "cWFKiumuvVuvrxM8xtunZxNM4FNUppSdyNm7HEqKjV3ZmENebD4DAf44kbyvq9fKJ61VzNrH3tYpLJgUrY8MEGW", + "slot": 442407364, + "instructionDataBase64": "AQEBAQEBAQEBb2VsYRw0lpkefGEc7V7lzQze6WnFPvyKVJeuBQgZse8A7SkSACqpcJ0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "accounts": [ + { + "address": "11111111111111111111111111111111", + "signer": false, + "writable": false + }, + { + "address": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", + "signer": false, + "writable": false + }, + { + "address": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "signer": false, + "writable": false + }, + { + "address": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", + "signer": false, + "writable": false + }, + { + "address": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "signer": false, + "writable": true + }, + { + "address": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "signer": false, + "writable": false + }, + { + "address": "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC", + "signer": true, + "writable": true + }, + { + "address": "7H2KAwXsrVWoAhY9ff1nNanYbp4amnF2mZdwzJDi9AhF", + "signer": true, + "writable": false + }, + { + "address": "GttQDgYR9gVLvjMofpVBiU6BgrJp5V7DFWfap6oLV6WY", + "signer": false, + "writable": true + }, + { + "address": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", + "signer": false, + "writable": false + }, + { + "address": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "signer": false, + "writable": true + }, + { + "address": "3hWynyfaw9gZxa7ik2vWfYGLp94Vcge3jb7Xa5qZ84k1", + "signer": false, + "writable": true + }, + { + "address": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "signer": false, + "writable": false + }, + { + "address": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "signer": false, + "writable": true + }, + { + "address": "11111111111111111111111111111111", + "signer": false, + "writable": false + }, + { + "address": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", + "signer": false, + "writable": true + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 invoke [1]", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi invoke [2]", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Protocol fee of 0 paid from 4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC to BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Dispatched message to 1634493807, ID 0xffe0409d00c184769b4dfa2a1eaac5a0a79bfe52458a38e1d9a71a9e5c677805", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi consumed 84675 of 980334 compute units", + "Program return: E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi /+BAnQDBhHabTfoqHqrFoKeb/lJFijjh2acanlxneAU=", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi success", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv invoke [2]", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Paid IGP JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M for 464000 gas for message 0xffe0\u20267805 to 1634493807", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv consumed 105639 of 892647 compute units", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv success", + "Program log: Warp route transfer completed to destination: 1634493807, recipient: 0x1c34\u20262912, remote_amount: 676200000000", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 consumed 225867 of 999700 compute units", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 success" + ], + "amountLamports": 676200000000, + "recipientAleoAddress": "aleo1rs6fdxg703s3em27uhxsehhfd8znaly22jt6upggrxc77q8d9yfq33pk28", + "senderAddress": "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC", + "uniqueMessageAddress": "7H2KAwXsrVWoAhY9ff1nNanYbp4amnF2mZdwzJDi9AhF", + "lamportDelta": 7023360 +} diff --git a/bridge-sdk/tests/live/__init__.py b/bridge-sdk/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py new file mode 100644 index 0000000..310e765 --- /dev/null +++ b/bridge-sdk/tests/live/cases.py @@ -0,0 +1,555 @@ +"""The funded live cases — one function per veil case, driven only by the public ``Bridge`` verbs. + +Ported from veil's `test/integration/live/mainnet/*.live.test.ts` (and `testnet/evm-xreserve`): +five cases, each parametrized over every registry route it covers, each resuming from its own +state file. The pytest suite and ``scripts/rehearse.py`` both call these functions, so the two can +never drift. + +Shape of every case (veil parity §4): + +1. load the state file for this route (fails closed; a completed case re-asserts and returns), +2. ``quote`` → print the route/amount/fee table → balance precheck (:class:`Underfunded` when the + wallet cannot cover it, which callers turn into a skip), +3. ``execute=False`` stops here — that is veil's ``if (!mainnetExecutionEnabled()) return``, +4. otherwise ``execute(plan, on_checkpoint=…)`` once, saving the checkpoint to the state file at + every boundary, then drop the in-memory progress and ``recover`` from what is on disk, +5. drive ``wait`` / ``resume`` / ``complete`` until ``next == "done"`` (``failed`` raises), +6. record the source tx, message id, destination tx and balance delta; mark the state completed. + +What this module will never do: retry ``execute`` after a broadcast (ambiguous or not), read or +set an acknowledgement variable (the caller passes ``execute=``), or print a key, a secret nonce, +an attestation or a record plaintext. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from aleo_bridge.errors import InsufficientBalanceError +from aleo_bridge.privacy import record_amount +from aleo_bridge.registry import Asset, Registry, Route +from aleo_bridge.units import format_decimal_amount + +from .config import one_atomic_unit +from .helpers import (LiveBenchmark, LiveCaseError, LiveState, Underfunded, ensure_secret_nonce, + load_live_state, load_secret_nonce, redacted, save_live_state, wait_for, + wait_for_aleo_transaction, wait_for_hyperlane_delivery) + +#: ERC-20 ``Transfer(address,address,uint256)``. +_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +#: veil's per-test budget (`30 * 60_000`). +CASE_TIMEOUT_SECONDS = 30 * 60.0 +WAIT_TIMEOUT_SECONDS = 20 * 60.0 +WAIT_POLL_SECONDS = 15.0 +_MAX_TRANSITIONS = 12 # a drive loop that cannot settle is a bug, not a slow chain + + +@dataclass(frozen=True) +class CaseSpec: + """One veil case: which routes it covers, what it sends, and how ``execute`` is parametrized.""" + + name: str + protocol: str # "hyperlane" | "xreserve" + source_family: str # "evm" | "aleo" | "solana" + mint_mode: str = "public" # "private" for the xReserve private mint into Aleo + mode: str | None = None # execute(mode=…): "signer" (Aleo Hyperlane) / "private" (Aleo burn) + amount: str | None = None # veil's literal, or None → one atomic unit of the source asset + veil_source: str = "" + + @property + def private_mint(self) -> bool: + return self.mint_mode == "private" + + +CASES: dict[str, CaseSpec] = { + "evm-hyperlane": CaseSpec( + name="evm-hyperlane", protocol="hyperlane", source_family="evm", + veil_source="mainnet/evm-hyperlane.live.test.ts:21-115"), + "evm-xreserve": CaseSpec( + name="evm-xreserve", protocol="xreserve", source_family="evm", mint_mode="private", + amount="2", # veil mainnet/evm-xreserve.live.test.ts:57 + veil_source="mainnet/evm-xreserve.live.test.ts:31-146"), + "aleo-hyperlane": CaseSpec( + name="aleo-hyperlane", protocol="hyperlane", source_family="aleo", mode="signer", + veil_source="mainnet/aleo-hyperlane.live.test.ts:47-164"), + "aleo-xreserve": CaseSpec( + name="aleo-xreserve", protocol="xreserve", source_family="aleo", mode="private", + amount="2.000001", # veil mainnet/aleo-xreserve.live.test.ts:97 + veil_source="mainnet/aleo-xreserve.live.test.ts:69-156"), + "solana-hyperlane": CaseSpec( + name="solana-hyperlane", protocol="hyperlane", source_family="solana", + veil_source="mainnet/solana-hyperlane.live.test.ts:20-91"), +} + +CASE_NAMES = tuple(CASES) + + +# ── routes, names, defaults ─────────────────────────────────────────────────── + +def routes_for_case(registry: Registry, case: str, environment: str = "mainnet") -> list[Route]: + """Every registry route *case* covers — both directions are separate cases, so this is one way. + + Includes non-active routes so the caller can report them as skipped-by-registry rather than + silently dropping them (veil parity §2). + """ + spec = CASES[case] + return [route for route in registry.routes(environment=environment, protocol=spec.protocol) + if registry.chain(registry.asset(route.source_asset_id).chain_id).family == spec.source_family] + + +def case_for_route(registry: Registry, route: Route) -> str | None: + """The case that covers *route*, or None — 13b asserts no active mainnet route returns None.""" + family = registry.chain(registry.asset(route.source_asset_id).chain_id).family + for spec in CASES.values(): + if spec.protocol == route.protocol and spec.source_family == family: + return spec.name + return None + + +def route_slug(route_id: str) -> str: + """``hyperlane:ethereum/eth->aleo/eth`` → ``hyperlane-ethereum-eth-aleo-eth`` (veil's state-name rule).""" + return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", route_id, flags=re.IGNORECASE)) + + +def state_name(case: str, route_id: str) -> str: + """The state file's base name. Always route-qualified, so a case that covers several routes + (evm-hyperlane over eth/wbtc/usdt, aleo-hyperlane over eth/wbtc/usdt/sol) can never resume one + route's checkpoint under another's — veil only did this for aleo-hyperlane.""" + return f"{case}-{route_slug(route_id)}" + + +def asset_ref(asset: Asset) -> str: + return f"{asset.chain_id}/{asset.key}" + + +def default_amount(registry: Registry, case: str, route: Route) -> str: + """veil's literal for the xReserve cases, otherwise one atomic unit of the source asset.""" + spec = CASES[case] + return spec.amount or one_atomic_unit(registry.asset(route.source_asset_id).decimals) + + +def sender_for(bridge: Any, route: Route) -> str | None: + """The address that will sign the source leg, read from the configured connection.""" + family = bridge.registry.chain(bridge.registry.asset(route.source_asset_id).chain_id).family + if family == "aleo": + return bridge.aleo_address() + connection = bridge.ethereum if family == "evm" else bridge.solana + if connection is None: + raise LiveCaseError(f"No {family} connection is configured for {route.id}") + return connection.address + + +def default_recipient(bridge: Any, route: Route) -> str: + """Our own address on the destination chain (the operator overrides it through the config vars).""" + family = bridge.registry.chain(bridge.registry.asset(route.destination_asset_id).chain_id).family + if family == "aleo": + return bridge.aleo_address() + # Ruling: probe the CONNECTION, never the bridge.eth/bridge.sol properties — those raise. + connection = bridge.ethereum if family == "evm" else bridge.solana + if connection is None or connection.address is None: + raise LiveCaseError(f"No {family} address is configured to receive {route.id}") + return connection.address + + +# ── printing (addresses and amounts only, never secrets) ────────────────────── + +def _human(bridge: Any, atomic: int, asset_id: str) -> str: + return f"{format_decimal_amount(atomic, bridge.registry.asset(asset_id).decimals)} " \ + f"{bridge.registry.asset(asset_id).symbol}" + + +def print_quote(bridge: Any, quote: Any, *, case: str, route_id: str, log: Callable[[str], None] = print) -> None: + """veil's ``console.table``: what is about to move, where, and what it costs.""" + plan = quote.plan + source, destination = bridge.registry.asset(plan.source_asset_id), bridge.registry.asset(plan.destination_asset_id) + log(f"\n== {case} [{plan.environment}] {route_id}") + log(f" kind {quote.kind} (registry {plan.registry_version})") + log(f" amount {plan.amount} {source.symbol} → {quote.amount_out or plan.amount} {destination.symbol}") + log(f" sender {plan.sender}") + log(f" recipient {plan.recipient}") + log(f" mint mode {plan.mint_mode}") + for fee in quote.fees: + estimated = " (estimated)" if fee.estimated else "" + log(f" fee {fee.amount} {bridge.registry.asset(fee.asset_id).symbol} [{fee.kind}]{estimated}") + # An EvmXReserveQuote carries no `fees`: its protocol cost is the max fee the deposit authorizes. + max_fee = getattr(quote, "max_fee_atomic", None) + if max_fee is not None: + log(f" fee {_human(bridge, max_fee, source.id)} [xReserve max fee]") + for name in ("native_value_atomic", "native_fee_atomic", "approval_required", "total_lamports", + "igp_lamports", "rent_lamports", "payment_microcredits", "balance_atomic", + "allowance_atomic", "withdrawal_fee_atomic", "max_fee_atomic"): + if hasattr(quote, name): + log(f" {name:<24}{getattr(quote, name)}") + steps = " → ".join(f"{step.id}{'*' if step.irreversible else ''}" for step in plan.steps) + log(f" steps {steps} (* irreversible)") + + +# ── balances ────────────────────────────────────────────────────────────────── + +def read_balances(bridge: Any) -> dict[str, int]: + """``asset_id → atomic`` across every configured chain, or ``{}`` when a chain cannot be read. + + ``Bridge.status()`` already walks exactly the configured connections, so an Aleo-only client + simply reports no EVM/Solana rows instead of raising. + """ + try: + return {asset_id: atomic for chain in bridge.status().chains for asset_id, atomic in chain.balances.items()} + except Exception: # noqa: BLE001 — a precheck must never fail the case + return {} + + +def _require(balances: dict[str, int], asset_id: str, needed: int, *, what: str = "balance", + log: Callable[[str], None] = print) -> None: + have = balances.get(asset_id) + if have is None: + log(f" {what:<10} {asset_id}: unreadable — proceeding; the chain will enforce it") + return + log(f" {what:<10} {asset_id}: {have} atomic (need {needed})") + if have < needed: + raise Underfunded(asset_id=asset_id, needed=needed, have=have, what=what) + + +def _native_asset_id(bridge: Any, chain_id: str) -> str | None: + native = [a for a in bridge.registry.assets(chain=chain_id) if a.kind == "native"] + return native[0].id if native else None + + +def precheck(bridge: Any, quote: Any, *, case: str, log: Callable[[str], None] = print) -> dict[str, int]: + """Refuse to spend what the wallet does not have; raises :class:`Underfunded` with the shortfall.""" + plan = quote.plan + balances = read_balances(bridge) + source = bridge.registry.asset(plan.source_asset_id) + native_id = _native_asset_id(bridge, source.chain_id) + + if quote.kind == "evm-hyperlane": + if source.kind == "native": + _require(balances, source.id, quote.native_value_atomic, log=log) + else: + _require(balances, source.id, plan.amount_atomic, log=log) + if native_id: + _require(balances, native_id, quote.native_fee_atomic, what="gas", log=log) + elif quote.kind == "solana-hyperlane": + if native_id: + _require(balances, native_id, quote.total_lamports, log=log) + elif quote.kind == "aleo-hyperlane": + _require(balances, source.id, plan.amount_atomic, log=log) + if native_id: + _require(balances, native_id, quote.payment_microcredits, what="hook fee", log=log) + elif quote.kind == "evm-xreserve": + # The quote already read the wallet's USDC balance and allowance on chain. + if quote.balance_atomic < plan.amount_atomic: + raise Underfunded(asset_id=source.id, needed=plan.amount_atomic, have=quote.balance_atomic) + log(f" balance {source.id}: {quote.balance_atomic} atomic (need {plan.amount_atomic})") + elif quote.kind == "aleo-xreserve": + pass # the private record is selected (and checked) right before the burn + return balances + + +# ── the shared engine ───────────────────────────────────────────────────────── + +def _saver(state: LiveState, state_path: Path, benchmark: LiveBenchmark) -> Callable[[Any], None]: + def save(checkpoint: Any) -> None: + state.checkpoint = checkpoint.to_dict() if hasattr(checkpoint, "to_dict") else dict(checkpoint) + source = state.checkpoint.get("source") or {} + state.source_tx_id = source.get("transactionId") or state.source_tx_id + destination = state.checkpoint.get("destination") or {} + state.destination_tx_id = destination.get("transactionId") or state.destination_tx_id + save_live_state(state_path, state) + benchmark.mark("checkpoint-saved") + return save + + +def _assert_completed(state: LiveState, case: str) -> LiveState: + """Re-running a finished case is a no-op that re-asserts what was recorded (veil parity §4).""" + if not state.source_tx_id: + raise LiveCaseError(f"{case} state claims completion without a source transaction id") + return state + + +def _recover_from_state(bridge: Any, state: LiveState, *, benchmark: LiveBenchmark, + log: Callable[[str], None]) -> Any: + """Rebuild progress from the checkpoint ON DISK, dropping whatever ``execute`` returned. + + The user's instruction requires the recovery verbs proven for real: the checkpoint the store + holds — found through ``bridge.pending()`` when a store is bound — is the only input here. + """ + if state.checkpoint is None: + raise LiveCaseError("No checkpoint was saved for this transfer; nothing to recover from") + receipt_id = state.checkpoint.get("receiptId") + pending = bridge.pending() or [] + if receipt_id and any(entry.receipt.id == receipt_id for entry in pending): + log(f" pending {receipt_id} is in the bound checkpoint store ({len(pending)} in flight)") + progress = bridge.recover(state.checkpoint) + benchmark.mark("source-recovered") + return progress + + +def _drive(bridge: Any, progress: Any, state: LiveState, state_path: Path, *, spec: CaseSpec, + secret_nonce: str | None, benchmark: LiveBenchmark, save: Callable[[Any], None], + wait_timeout_seconds: float, wait_poll_seconds: float, + log: Callable[[str], None]) -> Any: + """``wait`` / ``resume`` / ``complete`` until the transfer is done. Never calls ``execute``.""" + for _ in range(_MAX_TRANSITIONS): + if progress.next == "wait": + progress = bridge.wait(progress, timeout_seconds=wait_timeout_seconds, + poll_seconds=wait_poll_seconds, + on_error=lambda exc: log(f" transient {type(exc).__name__}: {exc}")) + benchmark.mark("wait-returned") + continue + if progress.next == "resume": + progress = bridge.resume(progress, on_checkpoint=save, secret_nonce=secret_nonce) + benchmark.mark("resume-returned") + continue + if progress.next == "complete": + if not secret_nonce: + raise LiveCaseError( + "The private mint needs the secret nonce kept beside this case's state file; " + "it is missing, so the mint cannot be completed here") + progress = bridge.complete(progress, secret_nonce=secret_nonce, on_checkpoint=save) + benchmark.mark("complete-returned") + continue + if progress.next == "failed": + raise LiveCaseError(f"{spec.name} failed: {progress.error}") + if progress.next == "done": + return progress + raise LiveCaseError(f"Unexpected progress state {progress.next!r} for {spec.name}") + raise LiveCaseError(f"{spec.name} did not settle after {_MAX_TRANSITIONS} lifecycle transitions") + + +def _select_private_record(bridge: Any, route: Route, amount_atomic: int, + log: Callable[[str], None]) -> str: + """The USDCx record the burn will spend, logged by amount and digest — never by plaintext.""" + program = route.meta_str("remoteToken") + try: + record = bridge.privacy.select_record(program, amount_atomic) + except InsufficientBalanceError as exc: + raise Underfunded(asset_id=bridge.registry.asset(route.source_asset_id).id, + needed=amount_atomic, have=0, what="private record") from exc + log(f" record {program} {redacted([record])} amount={record_amount(record)}") + return record + + +def delivery_is_a_balance_rise(route: Route, registry: Registry) -> bool: + """True for the Aleo→EVM xReserve withdrawal, the one leg with no delivery query anywhere. + + ``lifecycle.py`` says it in as many words ("xReserve Aleo→EVM: Circle exposes no canonical + delivery query") and simply returns the receipt unchanged, so its status stays + ``DELIVERY_PENDING`` for ever and ``wait`` can only ever time out. veil does not drive this + case to ``done`` either — ``aleo-xreserve.live.test.ts:146-155`` polls the recipient's ERC-20 + balance until it rises above what it was before the burn, and that is what delivery means here. + """ + if route.protocol != "xreserve": + return False + source = registry.chain(registry.asset(route.source_asset_id).chain_id).family + destination = registry.chain(registry.asset(route.destination_asset_id).chain_id).family + return source == "aleo" and destination == "evm" + + +def _wait_for_balance_rise(bridge: Any, asset_id: str, before: int, *, timeout_seconds: float, + poll_seconds: float, log: Callable[[str], None]) -> int: + """veil ``waitFor(... balanceOf > destinationBalanceBefore)``: the recipient's balance, once it rises.""" + def read() -> Any: + after = read_balances(bridge).get(asset_id) + return after if after is not None and after > before else None + + log(f" awaiting {asset_id} to rise above {before} atomic (no delivery query exists for this leg)") + return wait_for(read, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + + +def _evm_transfer_tx(bridge: Any, asset: Asset, recipient: str, amount_atomic: int, + lookback_blocks: int = 5_000) -> str | None: + """The transaction that moved *amount_atomic* of *asset* to *recipient*, or None. + + Best effort only: this is a convenience id for the report, so a public RPC that refuses the + log range (or returns nothing) leaves ``destinationTxId`` unset rather than failing a leg whose + funds have demonstrably arrived. + """ + connection = getattr(bridge, "ethereum", None) + if connection is None or asset.locator.kind != "evm-contract": + return None + try: + w3 = connection.w3 + head = w3.eth.block_number + entries = w3.eth.get_logs({ + "fromBlock": max(head - lookback_blocks, 0), "toBlock": head, + "address": w3.to_checksum_address(asset.locator.value), + "topics": [_TRANSFER_TOPIC, None, "0x" + "00" * 12 + recipient[2:].lower()]}) + except Exception: # noqa: BLE001 — a missing id is not a failure + return None + for entry in reversed(list(entries)): + raw = entry["data"] + value = int(raw.hex() if hasattr(raw, "hex") else raw, 16) + if value == amount_atomic: + digest = entry["transactionHash"] + return "0x" + (digest.hex() if hasattr(digest, "hex") else str(digest)).removeprefix("0x") + return None + + +def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, recipient: str | None = None, + amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, + wait_timeout_seconds: float = WAIT_TIMEOUT_SECONDS, wait_poll_seconds: float = WAIT_POLL_SECONDS, + stop_after_execute: bool = False, log: Callable[[str], None] = print) -> LiveState: + """Run one case over one route, resuming from ``state_path``. See the module docstring for the flow. + + ``stop_after_execute=True`` returns as soon as the source transaction is on chain and its + checkpoint is on disk, so the caller can throw the whole client away and prove that a *new* + ``Bridge`` finishes the transfer from the state file alone. Calling ``run_case`` again with the + same ``state_path`` takes the resume branch; ``execute`` is never called twice for one transfer. + """ + spec = CASES[case] + state_path = Path(state_path) + benchmark = benchmark if benchmark is not None else LiveBenchmark(case, log=log) + state = load_live_state(state_path, route_id) + if state.completed: + log(f" {case} {route_id} is already complete (source {state.source_tx_id})") + return _assert_completed(state, case) + + route = bridge.registry.route(route_id) + if not route.active: + raise LiveCaseError(f"Route {route_id} is {route.availability}; the registry will not execute it") + if route.protocol != spec.protocol: + raise LiveCaseError(f"Route {route_id} is {route.protocol}, not a {spec.protocol} case") + source, destination = bridge.registry.asset(route.source_asset_id), bridge.registry.asset(route.destination_asset_id) + amount = amount or default_amount(bridge.registry, case, route) + recipient = recipient or default_recipient(bridge, route) + sender = sender_for(bridge, route) + benchmark.mark("plan-prepared") + + # The private-mint nonce is created only when we are actually going to deposit: a quote-only + # rehearsal leaves no secret file behind. An existing one is always reused. + secret_nonce = None + if spec.private_mint: + secret_nonce = ensure_secret_nonce(state_path) if execute else load_secret_nonce(state_path) + state.secret_nonce_present = secret_nonce is not None + + progress = None + if state.checkpoint is None: + quote = bridge.quote(asset_ref(source), asset_ref(destination), amount=amount, recipient=recipient, + sender=sender, protocol=route.protocol, mint_mode=spec.mint_mode, + secret_nonce=secret_nonce or "0scalar") + benchmark.mark("quote-returned") + print_quote(bridge, quote, case=case, route_id=route_id, log=log) + balances = precheck(bridge, quote, case=case, log=log) + before = balances.get(destination.id) + if before is not None: + state.destination_balance_before = str(before) + if not execute: + log(f"\n quote only — nothing was submitted for {route_id}.") + return state + save_live_state(state_path, state) + + record = (_select_private_record(bridge, route, quote.plan.amount_atomic, log) + if case == "aleo-xreserve" else None) + save = _saver(state, state_path, benchmark) + gas = getattr(quote, "payment_microcredits", None) if spec.protocol == "hyperlane" else None + progress = bridge.execute(quote.plan, on_checkpoint=save, mode=spec.mode, record=record, + secret_nonce=secret_nonce, gas_payment_microcredits=gas) + benchmark.mark("execute-returned") + state.source_tx_id = progress.receipt.source_tx_id or state.source_tx_id + save_live_state(state_path, state) + if progress.receipt.protocol_state.get("blockhashExpired") is True: + raise LiveCaseError( + f"Solana source transaction {state.source_tx_id} expired; inspect it on chain before " + "clearing the checkpoint — never re-run execute") + if spec.source_family == "aleo" and state.source_tx_id: + # veil aleo-hyperlane:153 / aleo-xreserve:136: confirm the source on chain before + # recovering, so a rejected execution is reported as itself rather than as a timeout. + wait_for_aleo_transaction(bridge, state.source_tx_id) + benchmark.mark("source-confirmed") + if stop_after_execute: + log(f" handover source={state.source_tx_id} checkpoint on disk at {state_path}; " + "a new client will recover it") + return state + else: + log(f" resuming {case} {route_id} from the saved checkpoint") + + save = _saver(state, state_path, benchmark) + if not execute: + # A saved checkpoint plus no acknowledgement: report what is pending, submit nothing. + log(f"\n quote only — {route_id} has a saved checkpoint; re-run with the acknowledgement to finish it.") + return state + + progress = _recover_from_state(bridge, state, benchmark=benchmark, log=log) + + if delivery_is_a_balance_rise(route, bridge.registry): + # No drive loop: `wait` on this leg can only time out (see delivery_is_a_balance_rise). + before = int(state.destination_balance_before or 0) + after = _wait_for_balance_rise(bridge, destination.id, before, timeout_seconds=wait_timeout_seconds, + poll_seconds=wait_poll_seconds, log=log) + benchmark.mark("destination-delivered") + state.destination_tx_id = state.destination_tx_id or _evm_transfer_tx( + bridge, destination, recipient, after - before) + log(f" delivered {destination.id} +{after - before} atomic (before {before}, after {after}) " + f"tx={state.destination_tx_id}") + state.completed = True + save_live_state(state_path, state) + log(f" done source={state.source_tx_id} destination={state.destination_tx_id}") + log(f" {benchmark.summary()}") + return state + + progress = _drive(bridge, progress, state, state_path, spec=spec, secret_nonce=secret_nonce, + benchmark=benchmark, save=save, wait_timeout_seconds=wait_timeout_seconds, + wait_poll_seconds=wait_poll_seconds, log=log) + benchmark.mark("destination-delivered") + + receipt = progress.receipt + state.source_tx_id = receipt.source_tx_id or state.source_tx_id + state.message_id = receipt.protocol_state.get("messageId") or state.message_id + state.destination_tx_id = receipt.destination_tx_id or state.destination_tx_id + if spec.protocol == "hyperlane" and state.destination_tx_id is None and state.source_tx_id: + delivery = wait_for_hyperlane_delivery(state.source_tx_id, timeout_seconds=300, poll_seconds=15) + if delivery is not None: + state.message_id = state.message_id or delivery.message_id + state.destination_tx_id = delivery.destination_tx_id + else: + log(" explorer unavailable; the destination transaction id was not recorded") + state.completed = True + save_live_state(state_path, state) + after = read_balances(bridge).get(destination.id) + if after is not None and state.destination_balance_before is not None: + log(f" delivered {destination.id} +{after - int(state.destination_balance_before)} atomic " + f"(before {state.destination_balance_before}, after {after})") + log(f" done source={state.source_tx_id} message={state.message_id} " + f"destination={state.destination_tx_id}") + log(f" {benchmark.summary()}") + return state + + +def _runner(case: str) -> Callable[..., LiveState]: + def run(bridge: Any, route_id: str, *, state_path: Path | str, recipient: str | None = None, + amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, + **kwargs: Any) -> LiveState: + return run_case(bridge, case, route_id, state_path=state_path, recipient=recipient, amount=amount, + execute=execute, benchmark=benchmark, **kwargs) + run.__name__ = f"run_{case.replace('-', '_')}" + run.__doc__ = (f"veil {CASES[case].veil_source}: {case} over one route " + f"(amount {CASES[case].amount or 'one atomic unit'}" + f"{', private mint' if CASES[case].private_mint else ''}" + f"{f', execute mode {CASES[case].mode}' if CASES[case].mode else ''}).") + return run + + +run_evm_hyperlane = _runner("evm-hyperlane") +run_evm_xreserve = _runner("evm-xreserve") +run_aleo_hyperlane = _runner("aleo-hyperlane") +run_aleo_xreserve = _runner("aleo-xreserve") +run_solana_hyperlane = _runner("solana-hyperlane") + +RUNNERS: dict[str, Callable[..., LiveState]] = { + "evm-hyperlane": run_evm_hyperlane, + "evm-xreserve": run_evm_xreserve, + "aleo-hyperlane": run_aleo_hyperlane, + "aleo-xreserve": run_aleo_xreserve, + "solana-hyperlane": run_solana_hyperlane, +} + +__all__ = [ + "CASES", "CASE_NAMES", "CASE_TIMEOUT_SECONDS", "CaseSpec", "LiveCaseError", "RUNNERS", "Underfunded", + "asset_ref", "case_for_route", "default_amount", "default_recipient", "delivery_is_a_balance_rise", + "precheck", "print_quote", "read_balances", "route_slug", "routes_for_case", "run_aleo_hyperlane", + "run_aleo_xreserve", "run_case", "run_evm_hyperlane", "run_evm_xreserve", "run_solana_hyperlane", + "sender_for", "state_name", +] diff --git a/bridge-sdk/tests/live/config.py b/bridge-sdk/tests/live/config.py new file mode 100644 index 0000000..6f1ee9e --- /dev/null +++ b/bridge-sdk/tests/live/config.py @@ -0,0 +1,223 @@ +"""Gates and paths for the funded live cases — a port of veil's `test/integration/live/config.ts`. + +Every function here only READS the environment. Nothing in this repository sets, defaults or +repairs a gate variable: the acknowledgement strings exist so that a human types them into their +own shell for one command, and an agent that exports them has defeated the only safeguard between +a test run and somebody's money. Values are never logged — errors name the VARIABLE, never what +it contained. + +Gates (veil config.ts:17-36): + +* ``BRIDGE_LIVE_FUNDS=1`` **and** ``BRIDGE_LIVE_STATE_DIR=`` — live-funds + tests exist at all. +* ``BRIDGE_LIVE_MAINNET_ACK=I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS`` **and** + ``BRIDGE_LIVE_MAINNET_CASES=`` — the named mainnet case may run. +* ``BRIDGE_LIVE_MAINNET_EXECUTE=I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS`` — the wallet may + actually submit. Without it a case runs to the quote and returns (veil's + ``if (!mainnetExecutionEnabled()) return``). +""" +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Mapping + +#: The five mainnet cases veil ships, in the order the funding table lists them. +CASE_NAMES = ("evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") + +FUNDS_VAR = "BRIDGE_LIVE_FUNDS" +STATE_DIR_VAR = "BRIDGE_LIVE_STATE_DIR" +MAINNET_ACK_VAR = "BRIDGE_LIVE_MAINNET_ACK" +MAINNET_ACK = "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS" +MAINNET_CASES_VAR = "BRIDGE_LIVE_MAINNET_CASES" +MAINNET_EXECUTE_VAR = "BRIDGE_LIVE_MAINNET_EXECUTE" +MAINNET_EXECUTE_ACK = "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS" + +ENVIRONMENTS = ("mainnet", "testnet") + +#: Endpoints used when the operator's shell names none. Public, read-mostly, no credentials. +DEFAULT_ALEO_ENDPOINT = "https://edge.provable.com/api" +DEFAULT_ETHEREUM_RPC_URL = "https://ethereum-rpc.publicnode.com" +DEFAULT_SEPOLIA_RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com" + +#: Key and RPC variables per environment, most specific first. The live suite resolves connection +#: material ONLY through these lists, so a testnet run can never pick up the mainnet Aleo key. +ALEO_KEY_VARS = { + "mainnet": ("BRIDGE_PRIVATE_KEY",), + "testnet": ("BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY", "ALEO_E2E_PRIVATE_KEY"), +} +EVM_KEY_VARS = { + "mainnet": ("EVM_PRIVATE_KEY", "BRIDGE_EVM_PRIVATE_KEY"), + "testnet": ("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "EVM_PRIVATE_KEY", "BRIDGE_EVM_PRIVATE_KEY"), +} +EVM_RPC_VARS = { + "mainnet": ("ETHEREUM_RPC_URL", "BRIDGE_LIVE_ETHEREUM_RPC_URL"), + "testnet": ("SEPOLIA_RPC_URL", "BRIDGE_LIVE_SEPOLIA_RPC_URL"), +} +DEFAULT_EVM_RPC_URL = {"mainnet": DEFAULT_ETHEREUM_RPC_URL, "testnet": DEFAULT_SEPOLIA_RPC_URL} +ALEO_ENDPOINT_VARS = ("BRIDGE_LIVE_ALEO_ENDPOINT", "ALEO_ENDPOINT") + +#: The Aleo network name each bridge environment runs on. +ALEO_NETWORKS = {"mainnet": "mainnet", "testnet": "testnet"} + +#: Recipient overrides per destination-chain family; the default is our own address on that chain. +RECIPIENT_VARS = { + "aleo": "BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT", + "evm": "BRIDGE_LIVE_EVM_RECIPIENT", + "solana": "BRIDGE_LIVE_SOLANA_RECIPIENT", +} + +_EVM_KEY_RE = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE) + + +class LiveConfigError(Exception): + """A live case was explicitly enabled but is not configured (never carries a secret value).""" + + +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def value(name: str, env: Mapping[str, str] | None = None) -> str | None: + """The trimmed value of *name*, or None when unset or blank. Never logs.""" + raw = _env(env).get(name) + trimmed = raw.strip() if isinstance(raw, str) else None + return trimmed or None + + +def required(name: str, env: Mapping[str, str] | None = None) -> str: + """veil ``required()``: the value of *name*, or a clear error naming only the variable.""" + found = value(name, env) + if not found: + raise LiveConfigError(f"Missing {name}; the live bridge case was explicitly enabled but is not configured") + return found + + +def required_evm_private_key(name: str, env: Mapping[str, str] | None = None) -> str: + """veil ``requiredEvmPrivateKey()``: one 32-byte key normalised to ``0x…``; the value never appears.""" + raw = required(name, env) + body = raw[2:] if raw[:2].lower() == "0x" else raw + if not _EVM_KEY_RE.match(body): + raise LiveConfigError(f"{name} must contain exactly 32 hexadecimal bytes") + return f"0x{body.lower()}" + + +def live_funds_enabled(env: Mapping[str, str] | None = None) -> bool: + """veil ``liveFundsEnabled()``: funded cases exist at all (flag exactly ``"1"`` + a state dir).""" + source = _env(env) + return source.get(FUNDS_VAR) == "1" and bool(value(STATE_DIR_VAR, env)) + + +def mainnet_case_enabled(name: str, env: Mapping[str, str] | None = None) -> bool: + """veil ``mainnetCaseEnabled()``: funds gate + the exact acknowledgement + *name* in the case list.""" + if not live_funds_enabled(env): + return False + if _env(env).get(MAINNET_ACK_VAR) != MAINNET_ACK: + return False + listed = {entry.strip() for entry in (value(MAINNET_CASES_VAR, env) or "").split(",")} + return name in (listed - {""}) + + +def mainnet_execution_enabled(env: Mapping[str, str] | None = None) -> bool: + """veil ``mainnetExecutionEnabled()``: the wallet may submit. Read here, typed by a human elsewhere.""" + return _env(env).get(MAINNET_EXECUTE_VAR) == MAINNET_EXECUTE_ACK + + +def one_atomic_unit(decimals: int) -> str: + """veil ``oneAtomicUnit()``: the smallest positive display amount of an asset ("0.000001" at 6).""" + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise LiveConfigError(f"Invalid asset decimals: {decimals!r}") + return "1" if decimals == 0 else f"0.{'0' * (decimals - 1)}1" + + +def state_dir(env: Mapping[str, str] | None = None) -> Path: + """``BRIDGE_LIVE_STATE_DIR`` as a path — the operator's own directory, outside this repository.""" + return Path(required(STATE_DIR_VAR, env)).expanduser() + + +def live_state_path(environment: str, name: str, env: Mapping[str, str] | None = None) -> Path: + """veil ``liveStatePath()``: ``//.json``.""" + if environment not in ENVIRONMENTS: + raise LiveConfigError(f"environment must be one of {ENVIRONMENTS}, got {environment!r}") + return state_dir(env) / environment / f"{name}.json" + + +def case_route_override(case: str, env: Mapping[str, str] | None = None) -> str | None: + """``BRIDGE_LIVE__ROUTE_ID`` (veil's per-case route override), or None.""" + return value(f"BRIDGE_LIVE_{case.replace('-', '_').upper()}_ROUTE_ID", env) + + +def case_amount_override(case: str, env: Mapping[str, str] | None = None) -> str | None: + """``BRIDGE_LIVE__AMOUNT``, or ``BRIDGE_LIVE_XRESERVE_AMOUNT`` for either xReserve case.""" + specific = value(f"BRIDGE_LIVE_{case.replace('-', '_').upper()}_AMOUNT", env) + if specific: + return specific + return value("BRIDGE_LIVE_XRESERVE_AMOUNT", env) if case.endswith("xreserve") else None + + +def _environment(environment: str) -> str: + if environment not in ENVIRONMENTS: + raise LiveConfigError(f"environment must be one of {ENVIRONMENTS}, got {environment!r}") + return environment + + +def first_value(names: tuple[str, ...], env: Mapping[str, str] | None = None) -> tuple[str, str] | None: + """The first of *names* that is set, as ``(variable, value)`` — or None. Never logs the value.""" + for name in names: + found = value(name, env) + if found: + return name, found + return None + + +def aleo_private_key(environment: str, env: Mapping[str, str] | None = None) -> str: + """The Aleo key for *environment* (``BRIDGE_PRIVATE_KEY`` on mainnet, ``ALEO_E2E_PRIVATE_KEY`` + — or its ``BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`` alias — on testnet).""" + names = ALEO_KEY_VARS[_environment(environment)] + found = first_value(names, env) + if found is None: + raise LiveConfigError(f"Missing {' / '.join(names)}; the {environment} live case needs an Aleo key") + return found[1] + + +def evm_private_key(environment: str, env: Mapping[str, str] | None = None) -> str: + """The EVM key for *environment*, normalised to ``0x…`` (the value never appears in an error).""" + names = EVM_KEY_VARS[_environment(environment)] + found = first_value(names, env) + if found is None: + raise LiveConfigError(f"Missing {' / '.join(names)}; the {environment} live case needs an EVM key") + return required_evm_private_key(found[0], env) + + +def evm_rpc_url(environment: str, env: Mapping[str, str] | None = None) -> str: + """The Ethereum (mainnet) or Sepolia (testnet) RPC url, falling back to the public default.""" + found = first_value(EVM_RPC_VARS[_environment(environment)], env) + return found[1] if found else DEFAULT_EVM_RPC_URL[environment] + + +def aleo_endpoint(env: Mapping[str, str] | None = None) -> str: + """The Aleo API root, falling back to the open, credential-free edge host.""" + found = first_value(ALEO_ENDPOINT_VARS, env) + return found[1] if found else DEFAULT_ALEO_ENDPOINT + + +def recipient_override(family: str, env: Mapping[str, str] | None = None) -> str | None: + """The operator's recipient override for a destination-chain *family*, or None (use our own address).""" + try: + name = RECIPIENT_VARS[family] + except KeyError: + raise LiveConfigError(f"No recipient override variable for chain family {family!r}") from None + return value(name, env) + + +__all__ = [ + "ALEO_ENDPOINT_VARS", "ALEO_KEY_VARS", "ALEO_NETWORKS", "CASE_NAMES", "DEFAULT_ALEO_ENDPOINT", + "DEFAULT_ETHEREUM_RPC_URL", "DEFAULT_EVM_RPC_URL", "DEFAULT_SEPOLIA_RPC_URL", "ENVIRONMENTS", + "EVM_KEY_VARS", "EVM_RPC_VARS", "FUNDS_VAR", "LiveConfigError", "MAINNET_ACK", "MAINNET_ACK_VAR", + "MAINNET_CASES_VAR", "MAINNET_EXECUTE_ACK", "MAINNET_EXECUTE_VAR", "RECIPIENT_VARS", "STATE_DIR_VAR", + "aleo_endpoint", "aleo_private_key", "case_amount_override", "case_route_override", "evm_private_key", + "evm_rpc_url", "first_value", "live_funds_enabled", "live_state_path", "mainnet_case_enabled", + "mainnet_execution_enabled", "one_atomic_unit", "recipient_override", "required", + "required_evm_private_key", "state_dir", "value", +] diff --git a/bridge-sdk/tests/live/conftest.py b/bridge-sdk/tests/live/conftest.py new file mode 100644 index 0000000..b196241 --- /dev/null +++ b/bridge-sdk/tests/live/conftest.py @@ -0,0 +1,17 @@ +import os + +import pytest + +from aleo_bridge import Bridge + + +@pytest.fixture(scope="session") +def live_bridge() -> Bridge: + if os.environ.get("BRIDGE_LIVE_READS") != "1": + pytest.skip("set BRIDGE_LIVE_READS=1 (and BRIDGE_PRIVATE_KEY) to run read-only live checks") + if not os.environ.get("BRIDGE_PRIVATE_KEY"): + pytest.skip("BRIDGE_PRIVATE_KEY is required for Bridge.from_env()") + bridge = Bridge.from_env() + if bridge.environment != "mainnet": + pytest.skip("live read checks target the mainnet routes; unset ALEO_NETWORK or set it to mainnet") + return bridge diff --git a/bridge-sdk/tests/live/helpers.py b/bridge-sdk/tests/live/helpers.py new file mode 100644 index 0000000..7628a3b --- /dev/null +++ b/bridge-sdk/tests/live/helpers.py @@ -0,0 +1,393 @@ +"""Persistence, polling and timing for the funded live cases — a port of veil's +`test/integration/live/helpers.ts` (plus the secret-nonce file veil does not have). + +Nothing here signs or submits anything. What it does own is the memory of a run: the state file +that lets a case resume across processes, the private-mint secret that must survive a crash but +must never reach a log, a checkpoint or the state JSON, and the read-only lookups that answer +"did it arrive?". + +State files are veil-shaped on disk (camelCase keys, one JSON object per case) and fail CLOSED: +a corrupt or wrong-route file raises rather than quietly starting a fresh transfer over funds that +may already be in flight. +""" +from __future__ import annotations + +import json +import os +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable + +from aleo_bridge._base58 import b58decode +from aleo_bridge.encoding import validate_scalar +from aleo_bridge.lifecycle import aleo_transaction_status + +#: veil helpers.ts:85 — the Hyperlane explorer's public GraphQL endpoint (read-only). +HYPERLANE_EXPLORER_URL = "https://explorer4.hasura.app/v1/graphql" +HYPERLANE_QUERY = """query ByOrigin($hash: bytea!) { + message_view(where: {origin_tx_hash: {_eq: $hash}}, limit: 1) { + msg_id is_delivered destination_tx_hash + } +}""" + +DEFAULT_TIMEOUT_SECONDS = 1200.0 # veil helpers.ts:70 — 20 minutes +DEFAULT_POLL_SECONDS = 15.0 + +_SECRET_SUFFIX = ".secret" +_STATE_FIELDS = { + "route_id": "routeId", + "source_tx_id": "sourceTxId", + "message_id": "messageId", + "destination_tx_id": "destinationTxId", + "destination_balance_before": "destinationBalanceBefore", + "completed": "completed", + "checkpoint": "checkpoint", + "secret_nonce_present": "secretNoncePresent", +} +_STRING_FIELDS = ("source_tx_id", "message_id", "destination_tx_id", "destination_balance_before") + + +class LiveStateError(Exception): + """A state file could not be trusted: corrupt, malformed, or bound to another route.""" + + +class LiveTimeoutError(Exception): + """A live verification ran out of time; the transfer is still in flight (never a failure verdict).""" + + +class ExplorerError(Exception): + """The Hyperlane explorer answered with a GraphQL error rather than data.""" + + +class LiveCaseError(Exception): + """A live case cannot continue (a rejected source transaction, an expired blockhash, …).""" + + +class Underfunded(Exception): + """A wallet cannot cover amount + fees. Callers turn this into a skip, printing the shortfall.""" + + def __init__(self, *, asset_id: str, needed: int, have: int, what: str = "balance") -> None: + self.asset_id, self.needed, self.have, self.what = asset_id, needed, have, what + self.shortfall = max(needed - have, 0) + super().__init__(f"Insufficient {asset_id} {what}: need {needed} atomic units, have {have} " + f"(short {self.shortfall})") + + +# ── state files ─────────────────────────────────────────────────────────────── + +@dataclass +class LiveState: + """veil ``LiveState`` + ``secretNoncePresent``. + + The private-mint nonce itself is NOT a field: it lives in the sibling ``.secret`` file + (mode 0600) and only its presence is recorded here, so a state file can be pasted into a bug + report without leaking the commitment secret. + """ + + route_id: str + source_tx_id: str | None = None + message_id: str | None = None + destination_tx_id: str | None = None + destination_balance_before: str | None = None + completed: bool = False + checkpoint: dict[str, Any] | None = None + secret_nonce_present: bool = False + + def to_dict(self) -> dict[str, Any]: + """veil's on-disk shape: camelCase keys, unset optionals omitted.""" + raw = asdict(self) + out: dict[str, Any] = {} + for attribute, key in _STATE_FIELDS.items(): + value = raw[attribute] + if value is None or value is False: + continue + out[key] = value + out["routeId"] = self.route_id + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LiveState": + return cls(**{attribute: data.get(key) for attribute, key in _STATE_FIELDS.items() + if data.get(key) is not None}) + + +def _validate(state: LiveState, route_id: str, path: Path) -> LiveState: + if state.route_id != route_id: + raise LiveStateError(f"Live state route {state.route_id!r} does not match {route_id!r}: {path}") + for attribute in _STRING_FIELDS: + value = getattr(state, attribute) + if value is not None and not isinstance(value, str): + raise LiveStateError(f"Live state {_STATE_FIELDS[attribute]} is invalid: {path}") + if not isinstance(state.completed, bool): + raise LiveStateError(f"Live state completed flag is invalid: {path}") + if not isinstance(state.secret_nonce_present, bool): + raise LiveStateError(f"Live state secretNoncePresent flag is invalid: {path}") + if state.checkpoint is not None and not isinstance(state.checkpoint, dict): + raise LiveStateError(f"Live state checkpoint is invalid: {path}") + return state + + +def load_live_state(path: Path | str, route_id: str) -> LiveState: + """veil ``loadLiveState()``: the saved state for *route_id*, or an empty one when absent. + + Fails closed on corrupt JSON, a non-object payload, a wrong route or a malformed field — a + state file we cannot read is never treated as "no transfer in flight". + """ + target = Path(path) + try: + raw = target.read_text(encoding="utf-8") + except FileNotFoundError: + return LiveState(route_id=route_id) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise LiveStateError(f"Live state is not valid JSON: {target} ({exc})") from exc + if not isinstance(parsed, dict): + raise LiveStateError(f"Live state is not an object: {target}") + if not isinstance(parsed.get("routeId"), str): + raise LiveStateError(f"Live state routeId is invalid: {target}") + return _validate(LiveState.from_dict(parsed), route_id, target) + + +def save_live_state(path: Path | str, state: LiveState) -> Path: + """veil ``saveLiveState()``: atomic (temp file + ``os.replace``), mode 0600, parents 0700.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = target.with_name(f".{target.name}.tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(state.to_dict(), indent=2, sort_keys=True)) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, target) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + return target + + +# ── the private-mint secret nonce (beside the state, never inside it) ───────── + +def generate_secret_nonce() -> str: + """A fresh Aleo scalar literal for one private mint (248 random bits, below the scalar modulus).""" + import secrets + + return validate_scalar(f"{secrets.randbits(248)}scalar") + + +def secret_path(state_path: Path | str) -> Path: + """``.secret`` — the sibling file that holds the nonce for that case.""" + target = Path(state_path) + return target.with_name(target.name + _SECRET_SUFFIX) + + +def save_secret_nonce(state_path: Path | str, nonce: str) -> Path: + """Write *nonce* exclusively (``O_CREAT|O_EXCL``, 0600). An existing file raises rather than being + overwritten: the nonce of a deposit already on chain is the only way to finish that mint.""" + validate_scalar(nonce) + target = secret_path(state_path) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(f"{nonce}\n") + return target + + +def load_secret_nonce(state_path: Path | str) -> str | None: + """The kept nonce for this case, or None when there is no secret file.""" + try: + text = secret_path(state_path).read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + return validate_scalar(text) + + +def ensure_secret_nonce(state_path: Path | str) -> str: + """The case's nonce: the kept one if the secret file exists, otherwise a fresh one, saved once.""" + existing = load_secret_nonce(state_path) + if existing is not None: + return existing + nonce = generate_secret_nonce() + save_secret_nonce(state_path, nonce) + return nonce + + +# ── benchmark marks ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Mark: + step: str + elapsed_ms: int + total_ms: int + + +@dataclass +class LiveBenchmark: + """veil ``createLiveBenchmark()``: per-step and total elapsed milliseconds, printed as they happen. + + ``marks`` is kept so a pytest case can hand the whole timeline to ``record_property``. + """ + + label: str + now: Callable[[], float] = time.monotonic + log: Callable[[str], None] = print + marks: list[Mark] = field(default_factory=list) + + def __post_init__(self) -> None: + self._started = self.now() + self._previous = self._started + + def mark(self, step: str) -> Mark: + current = self.now() + entry = Mark(step, round((current - self._previous) * 1000), round((current - self._started) * 1000)) + self._previous = current + self.marks.append(entry) + self.log(f"[{self.label}] {step}: +{entry.elapsed_ms}ms (total {entry.total_ms}ms)") + return entry + + def summary(self) -> str: + if not self.marks: + return f"{self.label}: no marks" + steps = ", ".join(f"{m.step} +{m.elapsed_ms}ms" for m in self.marks) + return f"{self.label}: {steps} (total {self.marks[-1].total_ms}ms)" + + def as_dict(self) -> dict[str, int]: + return {m.step: m.elapsed_ms for m in self.marks} + + +# ── polling ─────────────────────────────────────────────────────────────────── + +def wait_for(read: Callable[[], Any], *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic) -> Any: + """veil ``waitFor()``: poll *read* until it returns something that is not None. + + Always reads at least once, then checks the deadline — so a zero timeout still performs one + read. A timeout raises :class:`LiveTimeoutError`; the transfer is not failed, it is unfinished, + and the state file on disk is how you resume it. + """ + deadline = now() + timeout_seconds + while True: + value = read() + if value is not None: + return value + if now() >= deadline: + raise LiveTimeoutError( + "Live bridge verification timed out; the transfer is still in flight — " + "resume from the persisted state file") + sleep(poll_seconds) + + +# ── Hyperlane explorer (read-only HTTP) ─────────────────────────────────────── + +@dataclass(frozen=True) +class HyperlaneDelivery: + message_id: str + destination_tx_id: str + + +def _post(url: str, payload: dict[str, Any], timeout: float) -> Any: + import requests + + return requests.post(url, json=payload, timeout=timeout, + headers={"content-type": "application/json"}) + + +def _bytea(source_tx_id: str) -> str: + """veil helpers.ts:81-83: an EVM ``0x`` hash or a Solana base58 signature → PostgreSQL bytea.""" + if source_tx_id.startswith("0x"): + return f"\\x{source_tx_id[2:]}" + return f"\\x{b58decode(source_tx_id).hex()}" + + +def _normalize(value: str) -> str: + return f"0x{value[2:]}" if value.startswith("\\x") else value + + +def hyperlane_delivery(source_tx_id: str, *, post: Callable[..., Any] = _post, + timeout: float = 30.0) -> HyperlaneDelivery | None: + """One read of the Hyperlane explorer for the message dispatched by *source_tx_id*. + + Returns None while the message is undelivered AND when the explorer is throttled or broken + (HTTP 429/5xx, or an unreachable host): a rate-limited explorer says nothing about the + transfer. A GraphQL error is a bug in the query and is raised (veil helpers.ts:104-106). + """ + payload = {"query": HYPERLANE_QUERY, "variables": {"hash": _bytea(source_tx_id)}} + try: + response = post(HYPERLANE_EXPLORER_URL, payload, timeout) + except Exception: # noqa: BLE001 — network flake, never a verdict + return None + status = getattr(response, "status_code", 200) + if status == 429 or status >= 500: + return None + if status >= 400: + raise ExplorerError(f"Hyperlane explorer returned HTTP {status}") + body = response.json() + errors = body.get("errors") if isinstance(body, dict) else None + if errors: + joined = "; ".join(str(e.get("message", "unknown error")) for e in errors) + raise ExplorerError(f"Hyperlane explorer query failed: {joined}") + rows = ((body.get("data") or {}).get("message_view") or []) if isinstance(body, dict) else [] + message = rows[0] if rows else None + if not message or not message.get("is_delivered") or not message.get("msg_id") \ + or not message.get("destination_tx_hash"): + return None + return HyperlaneDelivery(message_id=_normalize(message["msg_id"]), + destination_tx_id=_normalize(message["destination_tx_hash"])) + + +def wait_for_hyperlane_delivery(source_tx_id: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic, + post: Callable[..., Any] = _post) -> HyperlaneDelivery | None: + """Poll the explorer for the destination transaction id, or give up quietly. + + This lookup is a convenience on top of a leg the SDK has already called ``done``, so a timeout + or a throttled explorer returns None instead of failing the case (veil parity §8). + """ + try: + return wait_for(lambda: hyperlane_delivery(source_tx_id, post=post), + timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, sleep=sleep, now=now) + except LiveTimeoutError: + return None + + +# ── Aleo confirmation ───────────────────────────────────────────────────────── + +def wait_for_aleo_transaction(bridge: Any, tx_id: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic) -> None: + """veil ``waitForAleoTransaction()``: block until *tx_id* is accepted; a rejection raises.""" + + def read() -> Any: + status, error = aleo_transaction_status(bridge, tx_id) + if status == "rejected": + raise LiveCaseError(error or f"Aleo transaction {tx_id} was rejected") + return True if status == "accepted" else None + + wait_for(read, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, sleep=sleep, now=now) + + +def redacted(values: Iterable[str]) -> str: + """A stable, non-reversible tag for a secret-bearing string (a record plaintext), for logs.""" + import hashlib + + digest = hashlib.sha256("".join(values).encode("utf-8")).hexdigest() + return f"sha256:{digest[:12]}" + + +__all__ = [ + "DEFAULT_POLL_SECONDS", "DEFAULT_TIMEOUT_SECONDS", "ExplorerError", "HYPERLANE_EXPLORER_URL", + "HYPERLANE_QUERY", "HyperlaneDelivery", "LiveBenchmark", "LiveCaseError", "LiveState", + "LiveStateError", "LiveTimeoutError", "Mark", "Underfunded", "ensure_secret_nonce", + "generate_secret_nonce", "hyperlane_delivery", "load_live_state", "load_secret_nonce", "redacted", + "save_live_state", "save_secret_nonce", "secret_path", "wait_for", "wait_for_aleo_transaction", + "wait_for_hyperlane_delivery", +] diff --git a/bridge-sdk/tests/live/test_aleo_reads.py b/bridge-sdk/tests/live/test_aleo_reads.py new file mode 100644 index 0000000..df483ff --- /dev/null +++ b/bridge-sdk/tests/live/test_aleo_reads.py @@ -0,0 +1,138 @@ +"""Read-only checks against mainnet: IGP quotes, mailbox/nullifier reads, registry-vs-chain drift, mapping names. +A drift failure means the deployment moved since veil's review — STOP and report; never edit the registry to pass.""" +import os +import re + +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.client import BALANCE_MAPPING, balance_program +from aleo_bridge.freezelist import CURRENT_ROOT_KEY, FREEZE_LIST_LAST_INDEX_MAPPING, FREEZE_LIST_MAPPING, build_tree, generate_leaves +from aleo_bridge.hyperlane import compute_gas_payment, gas_config_key, parse_gas_config + +pytestmark = pytest.mark.live + +MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" +ALEO_ORIGIN = ["aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol"] + + +def _squash(text: str) -> str: + return re.sub(r"\s+", "", text) + + +@pytest.mark.parametrize("asset", ALEO_ORIGIN) +def test_igp_quote_is_a_positive_u64(live_bridge, asset): + quote = live_bridge.hyperlane.quote_gas_payment(asset) + route = live_bridge.hyperlane.outbound_route(asset) + assert quote.route_id == route.id + assert 0 < quote.payment_microcredits < 2**64 and quote.gas_price > 0 and quote.exchange_rate > 0 + assert quote.gas_limit == int(route.metadata["aleoRemoteRouterGas"]) + # Self-consistency: recompute the exact u64 from a raw mapping read taken independently of quote_gas_payment. + literal = live_bridge.mapping_value(route.meta_str("aleoHookManagerProgram"), "destination_gas_configs", + gas_config_key(route)) + assert literal is not None, f"destination_gas_configs is missing on chain for {route.id}" + config = parse_gas_config(literal) + assert config == {"gas_overhead": quote.gas_overhead, "gas_price": quote.gas_price, "exchange_rate": quote.exchange_rate} + recomputed = compute_gas_payment(gas_limit=quote.gas_limit, gas_overhead=config["gas_overhead"], + gas_price=config["gas_price"], exchange_rate=config["exchange_rate"]) + assert recomputed == quote.payment_microcredits + + +def test_mailbox_deliveries_vector(live_bridge, record_property): + delivered = live_bridge.hyperlane.is_delivered(MESSAGE_ID) + record_property("hyperlane_is_delivered_pinned_message", delivered) + assert delivered is True + assert live_bridge.hyperlane.is_delivered("0x" + "00" * 32) is False + + +def test_usdcx_bridge_nullifier_read(live_bridge): + assert live_bridge.xreserve.is_delivered(bytes(32)) is False # the zero nonce was never deposited + assert live_bridge.xreserve.inbound_route().metadata["bridgeProgram"] == "usdcx_bridge_v2.aleo" + + +@pytest.mark.parametrize("asset", ALEO_ORIGIN) +def test_registry_matches_deployed_warp_route_state(live_bridge, asset, record_property): + route = live_bridge.hyperlane.outbound_route(asset) + program = route.metadata["aleoRouterProgram"] + app = _squash(live_bridge.mapping_value(program, "app_metadata", "true") or "") + assert f"token_owner:{route.metadata['aleoTokenOwner']}" in app + assert f"token_id:{route.metadata['aleoTokenId']}" in app + assert f"local_decimals:{route.metadata['aleoLocalDecimals']}u8" in app + assert f"remote_decimals:{route.metadata['aleoRemoteDecimals']}u8" in app + raw_router = live_bridge.mapping_value(program, "remote_routers", f"{route.metadata['aleoDestinationDomain']}u32") + record_property(f"remote_routers_{asset}", raw_router) + if raw_router is None: + pytest.skip(f"{program}/remote_routers[{route.metadata['aleoDestinationDomain']}u32] did not parse; logged as None") + router = _squash(raw_router) + assert f"gas:{route.metadata['aleoRemoteRouterGas']}u128" in router + assert _squash(route.metadata["aleoRemoteRouterRecipient"]) in router + + +def test_mailbox_state_matches_registry(live_bridge): + route = live_bridge.hyperlane.outbound_route("aleo/eth") + mailbox = _squash(live_bridge.mapping_value(route.metadata["aleoMailboxProgram"], "mailbox", "true") or "") + assert f"default_hook:{route.metadata['aleoMailboxDefaultHook']}" in mailbox + assert f"required_hook:{route.metadata['aleoMailboxRequiredHook']}" in mailbox + + +def test_freeze_list_mappings_exist_and_proof_builds(live_bridge): + program_id = "usdcx_stablecoin.aleo" + fl_program = live_bridge.freezelist.freeze_list_program(program_id) + assert fl_program == "usdcx_freezelist.aleo" # the freeze-list mappings live on the freezelist program, not the token + names = live_bridge.program(fl_program).mappings() + assert FREEZE_LIST_MAPPING in names and FREEZE_LIST_LAST_INDEX_MAPPING in names, \ + f"freeze-list mapping names differ on chain: {sorted(names)} — update the two constants in freezelist.py only" + leaves = live_bridge.freezelist.leaves(program_id) + if "freeze_list_root" in names: + root = build_tree(generate_leaves(leaves), "mainnet")[-1] + on_chain_root = live_bridge.mapping_value(fl_program, "freeze_list_root", CURRENT_ROOT_KEY) + assert on_chain_root == f"{root}field", \ + f"leaves={leaves!r} recomputed root {root} != on-chain freeze_list_root[{CURRENT_ROOT_KEY}] {on_chain_root!r}" + # exclusion_proof() independently re-verifies the same on-chain root before building the proof. + proof = live_bridge.freezelist.exclusion_proof(live_bridge.aleo_address(), program_id) + assert proof.count("leaf_index") == 2 and proof.count("field") == 32 + + +def test_balance_mappings_exist_for_every_aleo_asset(live_bridge): + for asset in live_bridge.registry.assets(chain="aleo"): + program = balance_program(asset) + if program is None: + continue + names = live_bridge.program(program).mappings() + assert BALANCE_MAPPING in names, f"{program} declares {sorted(names)}; adjust BALANCE_MAPPING/balance_program in client.py" + + +def test_status_is_read_only_and_complete(live_bridge): + status = live_bridge.status() + assert status.chains[0].address == live_bridge.aleo_address() + assert set(status.chains[0].balances) == {a.id for a in live_bridge.registry.assets(chain="aleo")} + assert status.chains[0].balances["aleo/aleo"] > 0, \ + f"expected the live-test funding key to hold a positive credits balance, got {status.chains[0].balances['aleo/aleo']}" + + +def test_wrapper_program_is_deployed_with_expected_transitions(live_bridge): + assert enc.aleo_program_address("shielded_usdcx_wrapper.aleo", "mainnet") == \ + "aleo183r3zgsr57fwtgk5duzeq9kqdkpmmtfj4k5469ddvm3tcfhhls9szktw82" + functions = live_bridge.program("shielded_usdcx_wrapper.aleo").functions + assert "private_mint" in functions and "private_burn" in functions + bridge_functions = live_bridge.program("usdcx_bridge_v2.aleo").functions + assert "burn_public" in bridge_functions and "burn_public_as_signer" in bridge_functions + for asset in ALEO_ORIGIN: + router = live_bridge.program(live_bridge.hyperlane.outbound_route(asset).metadata["aleoRouterProgram"]).functions + assert "transfer_remote" in router and "transfer_remote_as_signer" in router + + +def test_transfer_remote_simulate(live_bridge): + if os.environ.get("BRIDGE_LIVE_SIMULATE") != "1": + pytest.skip("set BRIDGE_LIVE_SIMULATE=1 to build a local authorization (downloads proving parameters; no broadcast)") + call = live_bridge.hyperlane.transfer_remote("aleo/wbtc", "0x0000000000000000000000000000000000000001", amount_atomic=1, as_signer=True) + assert "amount: " in call.inputs[6] and call.inputs[5] == "1u128" + try: + authorization = call.simulate() + except Exception as exc: # noqa: BLE001 — the facade's own authorization error type varies by binding version + reason = str(exc) + if "balance" in reason.lower() or "insufficient" in reason.lower(): + pytest.xfail(reason=f"authorization failed for lack of a wrapped-WBTC balance on the live-test key: {reason}") + raise + assert authorization.function_name == "transfer_remote_as_signer" + assert live_bridge.aleo.network.get_latest_height() > 0 # sanity: the same client reaches the node; nothing was submitted diff --git a/bridge-sdk/tests/live/test_eth_reads.py b/bridge-sdk/tests/live/test_eth_reads.py new file mode 100644 index 0000000..6afb772 --- /dev/null +++ b/bridge-sdk/tests/live/test_eth_reads.py @@ -0,0 +1,108 @@ +"""Read-only mainnet checks: quotes for all three Hyperlane routes, the xReserve deposit quote, +Mailbox.delivered, and a pinned-address balance read. + +Gated by BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL. Nothing here signs or needs a key — the +``Ethereum`` connection is built with no ``private_key``/``signer``, and the ``Bridge`` is built +over a real (keyless) ``aleo.Aleo`` facade so only the ``bridge.eth`` surface is exercised. + +A public RPC's rate limiting (HTTP 429) or a transient 5xx is not a bug in this SDK, so those are +skips, not failures. +""" +import os + +import pytest +import requests + +from aleo_bridge.errors import InsufficientBalanceError +from aleo_bridge.eth import Ethereum + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_READS") != "1" or not os.environ.get("ETHEREUM_RPC_URL"), + reason="set BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL"), +] + +ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +# The xReserve contract custodies deposited USDC, so its balance exceeds the 2 USDC minimum and its +# allowance reads are meaningful. Any funded mainnet address works; keep it read-only. +PINNED_SENDER = "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" +KNOWN_MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" # delivered on Aleo + + +def _run(fn): + """Run *fn*; a 429/5xx from the public RPC is an environment condition, not a test failure.""" + try: + return fn() + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status == 429 or (status is not None and status >= 500): + pytest.skip(f"public RPC rate-limited (HTTP {status})") + raise + except requests.exceptions.ConnectionError as exc: + pytest.skip(f"public RPC unreachable: {exc}") + except requests.exceptions.Timeout: + pytest.skip("public RPC timed out") + + +@pytest.fixture(scope="module") +def eth(): + from aleo import Aleo, HTTPProvider + + from aleo_bridge import Bridge + + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) # no default_account: read-only + bridge = Bridge(aleo, ethereum=Ethereum(os.environ["ETHEREUM_RPC_URL"])) + return bridge.eth + + +def test_connection_is_mainnet_and_read_only(eth): + chain_id = _run(lambda: eth.conn.chain_id) + assert chain_id == 1 and not eth.conn.can_sign and eth.chain.id == "ethereum" + + +@pytest.mark.parametrize("asset,amount_atomic,router_type", [("eth", 1, "native"), ("wbtc", 1, "collateral"), ("usdt", 1, "collateral")]) +def test_hyperlane_quotes_at_minimum_amounts(eth, asset, amount_atomic, router_type, record_property): + q = _run(lambda: eth.quote_transfer_remote(asset, ALEO_RECIPIENT, amount_atomic=amount_atomic, sender=PINNED_SENDER)) + record_property(f"hyperlane_quote_{asset}", { + "native_fee_atomic": q.native_fee_atomic, "native_value_atomic": q.native_value_atomic, + "approval_required": q.approval_required}) + assert q.native_fee_atomic > 0 and q.plan.amount_atomic == amount_atomic + if router_type == "native": + assert q.native_value_atomic == amount_atomic + q.native_fee_atomic and q.approval_required is None + else: + assert q.native_value_atomic == q.native_fee_atomic and isinstance(q.approval_required, bool) + assert q.fees[0].asset_id == "ethereum/eth" and q.fees[0].estimated + + +def test_xreserve_deposit_quote_read_only(eth, record_property): + try: + q = _run(lambda: eth.quote_deposit_usdc(ALEO_RECIPIENT, amount="2", sender=PINNED_SENDER)) + except InsufficientBalanceError as exc: + # An accepted outcome (controller ruling): PINNED_SENDER's on-chain USDC balance may have + # moved since this test was written. The exception itself proves the balance-check mechanics + # ran (the SDK read PINNED_SENDER's USDC balance before building a quote). + record_property("xreserve_quote_outcome", f"InsufficientBalanceError: {exc}") + return + record_property("xreserve_quote_outcome", { + "route_id": q.plan.route_id, "max_fee_atomic": q.max_fee_atomic, "balance_atomic": q.balance_atomic}) + assert q.plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and q.max_fee_atomic == 100_000 + assert q.hook_data == bytes(65) and len(q.remote_recipient_bytes32) == 32 and q.balance_atomic >= 2_000_000 + + +def test_mailbox_delivered_reads(eth, record_property): + delivered = _run(lambda: eth.is_delivered(KNOWN_MESSAGE_ID)) + record_property("mailbox_delivered_known_id", delivered) + assert isinstance(delivered, bool) + assert _run(lambda: eth.is_delivered("0x" + "00" * 32)) is False + + +def test_balance_pinned_sender(eth, record_property): + value = _run(lambda: eth.balance("ethereum/eth", address=PINNED_SENDER)) + record_property("eth_balance_pinned_sender", value) + assert isinstance(value, int) and value >= 0 + + +def test_chain_status_read_only(eth): + status = _run(lambda: eth.chain_status()) + assert status.chain_id == "ethereum" and status.address is None and status.balances == {} diff --git a/bridge-sdk/tests/live/test_eth_sepolia_leg1.py b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py new file mode 100644 index 0000000..a118ab2 --- /dev/null +++ b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py @@ -0,0 +1,98 @@ +"""Leg 1 of the acceptance matrix: 2 USDC Sepolia -> USDCx aleo-testnet, public mint. + +Moves testnet funds. Gated by BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, +EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY. Every checkpoint and the final receipt are written to +BRIDGE_LIVE_STATE_DIR (outside the repo) so plan 4's rehearsal runner can `recover`/`wait` and +run leg 2 from them. Keys are read from the environment and never written or printed. + +Output layout matches spec §12 / plan 4's rehearsal convention: + - /checkpoints/.json — the FileCheckpointStore (one file per receipt id) + - /leg1.json — latest checkpoint boundary, overwritten atomically + - /receipts.jsonl — one appended line per leg run +""" +import json +import os +import tempfile +import time +from pathlib import Path + +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import Status + +REQUIRED = ("BRIDGE_LIVE_STATE_DIR", "SEPOLIA_RPC_URL", "EVM_PRIVATE_KEY", "ALEO_E2E_PRIVATE_KEY") +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_FUNDS") != "1" or any(not os.environ.get(v) for v in REQUIRED), + reason="set BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY"), +] + + +def _atomic_write(path: Path, text: str) -> None: + """Write *text* to *path* via a same-directory temp file + os.replace (no partial reads).""" + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + +def _bridge(): + from aleo import Aleo, HTTPProvider + + from aleo_bridge import Bridge + + aleo = Aleo(HTTPProvider(os.environ.get("ALEO_ENDPOINT", "https://edge.provable.com/api"), network="testnet")) + aleo.default_account = aleo.account.from_private_key(os.environ["ALEO_E2E_PRIVATE_KEY"]) + state_dir = Path(os.environ["BRIDGE_LIVE_STATE_DIR"]) + state_dir.mkdir(parents=True, exist_ok=True) + return Bridge(aleo, ethereum=Ethereum(os.environ["SEPOLIA_RPC_URL"], private_key=os.environ["EVM_PRIVATE_KEY"]), + checkpoints=FileCheckpointStore(state_dir / "checkpoints")), state_dir + + +def test_sepolia_usdc_deposit_public_mint(): + bridge, state_dir = _bridge() + eth = bridge.eth + assert eth.conn.chain_id == 11155111 and eth.chain.id == "sepolia" + recipient = bridge.aleo_address() + usdc_balance = eth.balance("usdc") + if usdc_balance < 2_000_000: + pytest.fail(f"{eth.conn.address} holds {usdc_balance} µUSDC on Sepolia; leg 1 needs 2 USDC plus ETH for gas") + quote = eth.quote_deposit_usdc(recipient, amount="2", mint_mode="public") + assert quote.plan.route_id == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and quote.plan.recipient == recipient + + checkpoint_path = state_dir / "leg1.json" + + def save(checkpoint): + _atomic_write(checkpoint_path, checkpoint.to_json() + "\n") # latest boundary wins; the store keeps every id + + result = eth.deposit_usdc(recipient, amount="2", mint_mode="public").send( + on_checkpoint=save, timeout_seconds=240.0, poll_seconds=3.0) + receipt = result.receipt + + with (state_dir / "receipts.jsonl").open("a", encoding="utf-8") as fh: + fh.write(json.dumps({ + "leg": 1, "route_id": result.route_id, "source_tx": receipt.source_tx_id, + "message_hash": result.message_hash, "status": receipt.status.value, + "delivery_confirmed": False, "amount_atomic": quote.plan.amount_atomic, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }) + "\n") + + assert checkpoint_path.exists() and bridge.checkpoints.load(receipt.id) is not None + assert receipt.status in (Status.ATTESTATION_PENDING, Status.SOURCE_CONFIRMING, Status.SOURCE_APPROVAL_PENDING) + if receipt.status == Status.ATTESTATION_PENDING: + assert len(result.message_hash) == 66 and len(result.nonce) == 66 + assert receipt.protocol_state["depositLogIndex"] >= 0 and receipt.protocol_state["mintMode"] == "public" + else: + # A timeout is not a failure: the receipt and checkpoint carry the hashes for recover()/source_status(). + refreshed = eth.source_status(quote.plan, receipt) + assert refreshed.status != Status.FAILED diff --git a/bridge-sdk/tests/live/test_lifecycle_live.py b/bridge-sdk/tests/live/test_lifecycle_live.py new file mode 100644 index 0000000..663187f --- /dev/null +++ b/bridge-sdk/tests/live/test_lifecycle_live.py @@ -0,0 +1,382 @@ +"""The funded live suite: every registry route, back and forth, through the public lifecycle verbs. + +One test per veil case, parametrized over every route that case covers. The parameter set is built +by ENUMERATING the registry, not by listing route ids by hand, so a new route cannot be added +without a case: :func:`_by_case` raises at import when a route has no case, and a route whose +``availability`` is not ``active`` is parametrized and skipped with ``registry:`` +rather than dropped. Plus the testnet deposit (`xreserve:sepolia/usdc->aleo-testnet/usdcx`) and, +beyond veil, the testnet RETURN leg (`xreserve:aleo-testnet/usdcx->sepolia/usdc`). + +Gates (read only — nothing here sets, exports or prints a settable acknowledgement): + +* ``BRIDGE_LIVE_FUNDS=1`` + ``BRIDGE_LIVE_STATE_DIR`` — the funded tests exist at all. +* ``BRIDGE_LIVE_MAINNET_ACK`` + ``BRIDGE_LIVE_MAINNET_CASES`` — a named mainnet case may run. +* ``BRIDGE_LIVE_MAINNET_EXECUTE`` — the wallet may submit. Without it every mainnet case runs to + the quote and returns (veil's ``if (!mainnetExecutionEnabled()) return``), which is how the + quote-only sweep in the report was produced. + +Every funded test goes through the disk: phase one quotes, prechecks and executes, then RETURNS — +the client is thrown away; phase two builds a brand new :class:`Bridge` over the same +``FileCheckpointStore`` and finishes the transfer from ``bridge.pending()`` / ``bridge.recover()``. +``execute`` is called at most once per transfer, ever; a timeout is *pending* (skip + the resume +command), never a failure verdict, and ``Underfunded`` is a skip printing the shortfall. +""" +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, Callable + +import pytest + +from aleo_bridge.errors import InsufficientBalanceError, PollingTimeoutError +from aleo_bridge.registry import DEFAULT_REGISTRY, Route + +from . import cases as live_cases +from . import config as live_config +from .helpers import LiveBenchmark, LiveTimeoutError, Underfunded + +pytestmark = pytest.mark.live + +#: The testnet pair. The deposit amount is 3 USDC (controller ruling 2026-09-18): the minted 3 +#: USDCx clears the 2 USDCx withdrawal fee so the return leg can burn veil's 2.000001 afterwards. +TESTNET_DEPOSIT_ROUTE = "xreserve:sepolia/usdc->aleo-testnet/usdcx" +TESTNET_RETURN_ROUTE = "xreserve:aleo-testnet/usdcx->sepolia/usdc" +TESTNET_DEPOSIT_AMOUNT = "3" +TESTNET_RETURN_AMOUNT = "2.000001" # veil mainnet/aleo-xreserve.live.test.ts:97 + + +# ── parametrization: enumerate the registry, leave nothing out ─────────────── + +def _by_case(environment: str) -> dict[str, list[Route]]: + """``case → routes`` for *environment*, asserting that every route has a case. + + Hermetic: the registry is static data, so this runs at import with no network and no keys. + """ + buckets: dict[str, list[Route]] = {name: [] for name in live_cases.CASE_NAMES} + uncovered: list[str] = [] + for route in DEFAULT_REGISTRY.routes(environment=environment): + name = live_cases.case_for_route(DEFAULT_REGISTRY, route) + if name is None: + uncovered.append(route.id) + else: + buckets[name].append(route) + if uncovered: + raise AssertionError( + f"No live case covers these {environment} routes: {sorted(uncovered)} — add the case " + "to tests/live/cases.py::CASES rather than narrowing this suite") + return buckets + + +MAINNET_ROUTES = _by_case("mainnet") +TESTNET_ROUTES = _by_case("testnet") + + +def _ids(routes: list[Route]) -> list[str]: + return [route.id for route in routes] + + +def _params(case: str) -> Any: + """``pytest.mark.parametrize`` arguments for one case: every mainnet route it covers.""" + routes = MAINNET_ROUTES[case] + return pytest.mark.parametrize("route", routes, ids=_ids(routes)) + + +# ── clients ────────────────────────────────────────────────────────────────── + +def _build_bridge(environment: str) -> Any: + """A fresh client for *environment* — new facade, new connections, same checkpoint store. + + Keys and endpoints are resolved only through ``config``: the testnet bridge can never pick up + the mainnet Aleo key, and an unset RPC variable falls back to the public default. + """ + from aleo_bridge import Bridge + from aleo_bridge.checkpoint import FileCheckpointStore + from aleo_bridge.client import build_aleo + from aleo_bridge.eth import Ethereum + from aleo_bridge.sol import Solana + + aleo = build_aleo(live_config.aleo_endpoint(), live_config.ALEO_NETWORKS[environment], + live_config.aleo_private_key(environment)) + ethereum = Ethereum(live_config.evm_rpc_url(environment), + private_key=live_config.evm_private_key(environment)) + solana = Solana.from_env() if environment == "mainnet" else None + store = FileCheckpointStore(live_config.state_dir() / environment / "checkpoints") + bridge = Bridge(aleo, ethereum=ethereum, solana=solana, checkpoints=store) + assert bridge.environment == environment + return bridge + + +#: Opt-in for sharing a MAINNET view key with the hosted record scanner (see below). +SCANNER_VAR = "BRIDGE_LIVE_ALEO_SCANNER" + + +def _register_record_scanner(bridge: Any, environment: str) -> None: + """Register the Aleo account with the hosted record scanner, which private selection needs. + + ``bridge.privacy.select_record`` (the record the xReserve burn spends) goes through + ``aleo.records.find``, and the scanner answers nothing for an account it has not been + registered for — it raises ``UUIDError`` instead. Registration **shares that account's view + key** with the scanning service, which can then decrypt every record the account owns. + + That is a decision about somebody's privacy, so it is automatic only for the testnet e2e key. + On mainnet the case skips unless ``BRIDGE_LIVE_ALEO_SCANNER=1`` says the operator accepts it; + nothing here ever sets that variable. + """ + if environment != "testnet" and live_config.value(SCANNER_VAR) != "1": + pytest.skip(f"private-record selection needs the hosted record scanner, and registering " + f"shares this account's view key with it — set {SCANNER_VAR}=1 to accept that") + result = bridge.aleo.records.register(bridge.aleo.default_account) + ok = result.get("ok") if isinstance(result, dict) else result + print(f" scanner hosted record scanner registered for {bridge.aleo_address()} (ok={ok})") + + +def _client(make_bridge: Callable[[], Any], case: str, environment: str, *, execute: bool) -> Any: + """A fresh bridge, with the record scanner registered for the cases that select private records. + + Only when we are actually going to burn: a quote-only rehearsal never selects a record, so it + must not make a view-key decision (nor skip for want of one) on the operator's behalf. + """ + bridge = make_bridge() + if execute and case == "aleo-xreserve": + _register_record_scanner(bridge, environment) + return bridge + + +@pytest.fixture +def mainnet_client() -> Callable[[], Any]: + return lambda: _build_bridge("mainnet") + + +@pytest.fixture +def testnet_client() -> Callable[[], Any]: + return lambda: _build_bridge("testnet") + + +# ── the shared body ────────────────────────────────────────────────────────── + +def _state_path(case: str, route: Route, environment: str) -> Path: + return live_config.live_state_path(environment, live_cases.state_name(case, route.id)) + + +def resume_command(state_path: Path | str) -> str: + """What the operator types to continue an interrupted transfer (the CLI, not a test run).""" + return f"python scripts/rehearse.py --recover {state_path}" + + +def _destination_family(route: Route) -> str: + asset = DEFAULT_REGISTRY.asset(route.destination_asset_id) + return DEFAULT_REGISTRY.chain(asset.chain_id).family + + +def _record(record_property: Callable[[str, Any], None], case: str, route: Route, state: Any, + benchmark: LiveBenchmark) -> None: + record_property("case", case) + record_property("route_id", route.id) + record_property("source_tx_id", getattr(state, "source_tx_id", None)) + record_property("message_id", getattr(state, "message_id", None)) + record_property("destination_tx_id", getattr(state, "destination_tx_id", None)) + record_property("destination_balance_before", getattr(state, "destination_balance_before", None)) + record_property("completed", bool(getattr(state, "completed", False))) + record_property("benchmark_ms", benchmark.as_dict()) + + +def _drive_case(make_bridge: Callable[[], Any], case: str, route: Route, *, environment: str, + execute: bool, record_property: Callable[[str, Any], None], + amount: str | None = None) -> Any: + """Quote (and, when acknowledged, execute) one route, then finish it from disk with a NEW client. + + The two phases are the point: whatever ``execute`` returned is dropped with the first client, so + the transfer can only reach ``done`` through ``bridge.pending()`` → ``bridge.recover()`` → + ``wait``/``resume``/``complete`` on a process that never saw it start. + """ + if not route.active: + pytest.skip(f"registry:{route.availability}") + + state_path = _state_path(case, route, environment) + benchmark = LiveBenchmark(f"{case}:{live_cases.route_slug(route.id)}", log=print) + amount = amount or live_config.case_amount_override(case) + recipient = live_config.recipient_override(_destination_family(route)) + deadline = time.monotonic() + live_cases.CASE_TIMEOUT_SECONDS + + def remaining() -> float: + return max(deadline - time.monotonic(), 0.0) + + def run(bridge: Any, *, stop_after_execute: bool) -> Any: + return live_cases.run_case( + bridge, case, route.id, state_path=state_path, recipient=recipient, amount=amount, + execute=execute, benchmark=benchmark, stop_after_execute=stop_after_execute, + wait_timeout_seconds=min(live_cases.WAIT_TIMEOUT_SECONDS, remaining()), + wait_poll_seconds=live_cases.WAIT_POLL_SECONDS, log=print) + + state = None + try: + bridge = _client(make_bridge, case, environment, execute=execute) + benchmark.mark("clients-created") + state = run(bridge, stop_after_execute=True) + if execute and state.checkpoint is not None and not state.completed: + print(f" handover dropping the client that executed {route.id}; " + "a new one will recover from disk") + del bridge # the in-memory progress goes with it + bridge = _client(make_bridge, case, environment, execute=execute) + benchmark.mark("clients-recreated") + state = run(bridge, stop_after_execute=False) + except Underfunded as exc: + record_property("underfunded", {"asset_id": exc.asset_id, "needed": exc.needed, + "have": exc.have, "shortfall": exc.shortfall, "what": exc.what}) + print(f"\n UNDERFUNDED {route.id}: {exc}") + pytest.skip(f"{route.id}: {exc}") + except InsufficientBalanceError as exc: + # Some quotes read the wallet themselves and refuse before our precheck ever runs (the + # xReserve deposit quote is one). That is the same verdict, reached one step earlier. + record_property("underfunded", {"raised_by": "quote", "detail": str(exc)}) + print(f"\n UNDERFUNDED {route.id}: the quote refused — {exc}") + pytest.skip(f"{route.id}: {exc}") + except (PollingTimeoutError, LiveTimeoutError) as exc: + record_property("pending_state_path", str(state_path)) + record_property("pending_resume", resume_command(state_path)) + print(f"\n PENDING {route.id} is still in flight: {exc}\n" + f" resume {resume_command(state_path)}") + pytest.skip(f"{route.id} still in flight (state {state_path}); resume: {resume_command(state_path)}") + + _record(record_property, case, route, state, benchmark) + if not execute: + # veil's `if (!mainnetExecutionEnabled()) return` — the quote and the precheck are the test, + # and the one thing that must hold is that nothing was submitted for an unstarted transfer. + assert state.completed or state.checkpoint is None, \ + f"{route.id} has an in-flight checkpoint that the quote-only run must not have created" + return state + + assert state.completed, f"{route.id} did not reach done: {state}" + assert state.source_tx_id, f"{route.id} completed without a source transaction id" + if not live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY): + assert state.message_id or state.destination_tx_id, \ + f"{route.id} completed without a message id or a destination transaction id" + + # Delivery is checked as "at least what was quoted", never as equality. A quote's `amount_out` + # is derived from the registry's fee literal, and the fee the protocol actually charges is live + # state: the 2026-09-18 testnet return burned 2.000001 USDCx against a registry + # withdrawalFeeAtomic of 2_000_000 (quote: 0.000001 USDC out) and delivered 0.996501 USDC, + # because Circle's testnet withdrawal fee was 1.0035 USDC that day. A private mint moves no + # public balance at all, so a zero delta is also correct. + if state.destination_balance_before is not None: + after = live_cases.read_balances(bridge).get(route.destination_asset_id) + if after is not None: + delta = after - int(state.destination_balance_before) + record_property("destination_balance_delta", delta) + print(f" delta {route.destination_asset_id} +{delta} atomic " + f"(before {state.destination_balance_before}, after {after})") + assert delta >= 0, f"{route.id} delivered a negative balance delta ({delta})" + if live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY): + assert delta > 0, f"{route.id} was marked delivered but nothing arrived" + print(f"\n SUMMARY {case} {route.id} source={state.source_tx_id} " + f"message={state.message_id} destination={state.destination_tx_id}") + return state + + +def _mainnet(case: str, route: Route, make_bridge: Callable[[], Any], + record_property: Callable[[str, Any], None]) -> Any: + """Gate one mainnet route: funds + the case acknowledgement; submission needs its own.""" + if not live_config.live_funds_enabled(): + pytest.skip(f"set {live_config.FUNDS_VAR}=1 and {live_config.STATE_DIR_VAR} to run funded live cases") + if not live_config.mainnet_case_enabled(case): + pytest.skip(f"{live_config.MAINNET_ACK_VAR} and {live_config.MAINNET_CASES_VAR} do not enable {case}") + return _drive_case(make_bridge, case, route, environment="mainnet", + execute=live_config.mainnet_execution_enabled(), record_property=record_property) + + +def _testnet(case: str, route_id: str, make_bridge: Callable[[], Any], + record_property: Callable[[str, Any], None], amount: str) -> Any: + """Gate one testnet route: the funds gate alone (veil's testnet file has no mainnet acknowledgement).""" + if not live_config.live_funds_enabled(): + pytest.skip(f"set {live_config.FUNDS_VAR}=1 and {live_config.STATE_DIR_VAR} to run funded live cases") + route = DEFAULT_REGISTRY.route(route_id) + return _drive_case(make_bridge, case, route, environment="testnet", execute=True, + record_property=record_property, amount=amount) + + +# ── the five mainnet cases, one test each, over every route they cover ─────── + +@_params("evm-hyperlane") +def test_evm_hyperlane(route, mainnet_client, record_property): + """veil mainnet/evm-hyperlane.live.test.ts: ethereum → aleo over the Hyperlane warp routes.""" + _mainnet("evm-hyperlane", route, mainnet_client, record_property) + + +@_params("evm-xreserve") +def test_evm_xreserve(route, mainnet_client, record_property): + """veil mainnet/evm-xreserve.live.test.ts: 2 USDC ethereum → aleo, private mint (needs `complete`).""" + _mainnet("evm-xreserve", route, mainnet_client, record_property) + + +@_params("aleo-hyperlane") +def test_aleo_hyperlane(route, mainnet_client, record_property): + """veil mainnet/aleo-hyperlane.live.test.ts: aleo → ethereum and aleo → solana, `mode="signer"`.""" + _mainnet("aleo-hyperlane", route, mainnet_client, record_property) + + +@_params("aleo-xreserve") +def test_aleo_xreserve(route, mainnet_client, record_property): + """veil mainnet/aleo-xreserve.live.test.ts: 2.000001 USDCx private burn, aleo → ethereum.""" + _mainnet("aleo-xreserve", route, mainnet_client, record_property) + + +@_params("solana-hyperlane") +def test_solana_hyperlane(route, mainnet_client, record_property): + """veil mainnet/solana-hyperlane.live.test.ts: 1 lamport solana → aleo.""" + _mainnet("solana-hyperlane", route, mainnet_client, record_property) + + +# ── recovery for real (controller note 10, brief deliverable 2) ────────────── + +@pytest.mark.parametrize("route", MAINNET_ROUTES["evm-hyperlane"][:1], + ids=_ids(MAINNET_ROUTES["evm-hyperlane"][:1])) +def test_evm_hyperlane_recovers_from_disk_after_execute(route, mainnet_client, record_property): + """The ETH route, finished by a client that never saw ``execute``. + + This is the same two-phase body every funded test uses, named separately because the parity doc + calls it out: the checkpoint written by phase one is looked up through ``bridge.pending()`` on a + brand-new ``Bridge`` bound to the same ``FileCheckpointStore``, rebuilt with ``bridge.recover`` + and driven to ``done`` with ``wait``. + """ + state = _mainnet("evm-hyperlane", route, mainnet_client, record_property) + if live_config.mainnet_execution_enabled(): + assert state.checkpoint is not None, "the transfer must have left a checkpoint on disk" + + +# ── testnet: the pair that actually runs today ─────────────────────────────── + +def test_testnet_evm_xreserve_deposit(testnet_client, record_property): + """veil testnet/evm-xreserve.live.test.ts: 3 USDC Sepolia → aleo-testnet USDCx, private mint. + + 3 rather than veil's 2 (controller ruling 2026-09-18) so the minted balance clears the 2 USDCx + withdrawal fee and :func:`test_testnet_aleo_xreserve_return` can burn straight afterwards. + """ + _testnet("evm-xreserve", TESTNET_DEPOSIT_ROUTE, testnet_client, record_property, + TESTNET_DEPOSIT_AMOUNT) + + +def test_testnet_aleo_xreserve_return(testnet_client, record_property): + """Beyond veil: the RETURN leg, aleo-testnet USDCx → Sepolia USDC, 2.000001 private burn. + + Runs the mainnet ``aleo-xreserve`` case function against the testnet route, so "all the routes + back and forth" holds on testnet too. It spends what the deposit minted — run that first; an + unfunded run skips with the shortfall. + """ + _testnet("aleo-xreserve", TESTNET_RETURN_ROUTE, testnet_client, record_property, + TESTNET_RETURN_AMOUNT) + + +# ── the coverage invariant, as a test as well as an import-time assertion ──── + +def test_every_registry_route_has_a_case(): + """'All the routes back and forth': no route in either environment may be left without a case.""" + for environment in live_config.ENVIRONMENTS: + buckets = _by_case(environment) + listed = {route.id for routes in buckets.values() for route in routes} + assert listed == {route.id for route in DEFAULT_REGISTRY.routes(environment=environment)} + assert {route.id for route in TESTNET_ROUTES["evm-xreserve"]} == {TESTNET_DEPOSIT_ROUTE} + assert {route.id for route in TESTNET_ROUTES["aleo-xreserve"]} == {TESTNET_RETURN_ROUTE} + + +__all__ = ["MAINNET_ROUTES", "TESTNET_ROUTES", "resume_command"] diff --git a/bridge-sdk/tests/live/test_sol_reads.py b/bridge-sdk/tests/live/test_sol_reads.py new file mode 100644 index 0000000..38107bd --- /dev/null +++ b/bridge-sdk/tests/live/test_sol_reads.py @@ -0,0 +1,82 @@ +"""Read-only mainnet checks for the Solana side. Gate: BRIDGE_LIVE_READS=1. + +No key, no funds: decodes the live inner IGP account and quotes leg 11 (SOL -> Aleo, 1 lamport) for a +pinned sender. Fund-moving legs 11-12 run from scripts/rehearse.py (plan 4). + +A public RPC's rate limiting (HTTP 429) or a transient 5xx is not a bug in this SDK, so those are +skips, not failures (mirrors tests/live/test_eth_reads.py's ``_run`` helper). +""" +import os +import re + +import pytest + +pytest.importorskip("solders") + +from aleo import Aleo, HTTPProvider + +from aleo_bridge import Bridge, Solana +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT, TRANSFER, igp_account_data + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(os.environ.get("BRIDGE_LIVE_READS") != "1", reason="set BRIDGE_LIVE_READS=1 to hit mainnet RPCs"), +] + +ALEO_ENDPOINT = os.environ.get("ALEO_ENDPOINT", "https://edge.provable.com/api") +SENDER = os.environ.get("BRIDGE_LIVE_SOLANA_SENDER", TRANSFER["senderAddress"]) +RECIPIENT = os.environ.get("BRIDGE_LIVE_ALEO_RECIPIENT", TRANSFER["recipientAleoAddress"]) + +_HTTP_STATUS_RE = re.compile(r"HTTP status (\d+)") + + +def _run(fn): + """Run *fn*; a 429/5xx (or an unreachable public RPC) is an environment condition, not a test failure.""" + try: + return fn() + except BridgeError as exc: + message = str(exc) + match = _HTTP_STATUS_RE.search(message) + if match and (int(match.group(1)) == 429 or int(match.group(1)) >= 500): + pytest.skip(f"public RPC rate-limited or unavailable ({message})") + if "request failed:" in message: + pytest.skip(f"public RPC unreachable: {message}") + raise + + +@pytest.fixture(scope="module") +def bridge() -> Bridge: + aleo = Aleo(HTTPProvider(ALEO_ENDPOINT, network="mainnet")) + return Bridge(aleo, solana=Solana(os.environ.get("SOLANA_RPC_URL"))) + + +def test_live_igp_account_decodes_with_the_fixture_shape(bridge): + metadata = _run(lambda: bridge.sol.metadata()) + live = _run(lambda: bridge.sol._account_data(metadata.igp_account)) + assert live is not None + account = sl.decode_igp_account(live) + recorded = sl.decode_igp_account(igp_account_data()) + assert account.bump == recorded.bump and account.beneficiary == recorded.beneficiary + assert account.owner == recorded.owner + oracle = account.gas_oracles[ALEO_MAINNET_DOMAIN] + assert oracle.token_decimals == recorded.gas_oracles[ALEO_MAINNET_DOMAIN].token_decimals == 6 + assert oracle.token_exchange_rate > 0 and oracle.gas_price > 0 + lamports = sl.quote_igp_lamports(live, ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT) + assert 0 < lamports < 1_000_000_000 # sanity: below 1 SOL; the recorded value was 2_900_000 + + +def test_live_leg_11_quote_for_a_pinned_sender(bridge, record_property): + quote = _run(lambda: bridge.sol.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER)) + assert quote.plan.route_id == sl.SOLANA_ROUTE_ID and quote.plan.sender == SENDER + assert quote.igp_lamports > 0 and quote.network_fee_lamports > 0 + client = bridge.solana.client + rents = [int(_run(lambda n=n: client.get_minimum_balance_for_rent_exemption(n)).value) for n in (141, 194, 0)] + assert quote.rent_lamports == sum(rents) + assert quote.total_lamports == 1 + quote.igp_lamports + quote.network_fee_lamports + quote.rent_lamports + record_property("leg_11_quote", { + "igp_lamports": quote.igp_lamports, "network_fee_lamports": quote.network_fee_lamports, + "rent_lamports": quote.rent_lamports, "total_lamports": quote.total_lamports}) + print(f"leg 11 quote: igp={quote.igp_lamports} fee={quote.network_fee_lamports} " + f"rent={quote.rent_lamports} total={quote.total_lamports}") diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py new file mode 100644 index 0000000..163a0f1 --- /dev/null +++ b/bridge-sdk/tests/test_agent.py @@ -0,0 +1,436 @@ +"""Agent tools: the Claude-shape surface, the confirm gate, and what must never leave the process. + +The brief's own cases (surface/schemas, _serialize, reads, the four write gates) are kept as +written; the controller's rulings add: no "0scalar" fallback for a private mint, no secret in any +tool result, the synthetic xReserve max-fee entry, structured errors for an unconfigured chain, +and `next: "recover"` guidance when a send's outcome is ambiguous. +""" +import json + +import pytest + +from aleo_bridge.agent import _serialize, _summarize_input, bridge_tools, dispatch_tool +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint +from aleo_bridge.errors import BridgeError +from aleo_bridge.lifecycle import prepare +from aleo_bridge.types import Fee, Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, RECORD_PLAINTEXT, FakeBridge + +READS = {"bridge_status", "bridge_list_assets", "bridge_list_routes", "bridge_quote", "bridge_get_progress", + "bridge_pending"} +WRITES = {"bridge_execute", "bridge_resume", "bridge_complete", "bridge_shield", "bridge_unshield"} +QUOTE_ARGS = {"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", "recipient": ALEO_RECIPIENT} +NONCE = "7scalar" + + +def _aleo_out_checkpoint(b): + """A checkpoint for an Aleo-origin Hyperlane transfer that was proved but never broadcast.""" + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + cp = {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, + "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": plan.amount, "recipient": EVM_ADDRESS}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"preparedTransaction": {"transactionId": "at1prepared", + "serializedTransaction": serialized}}} + return cp, serialized + + +def _inbound_private_checkpoint(b): + """A testnet xReserve deposit attested and waiting for its private mint.""" + from aleo_bridge.types import Attestation + from tests.test_get_status import SIG, _inbound_private + + plan, payload, message_hash, receipt = _inbound_private(b) + b.xreserve.attestations[message_hash] = Attestation(payload, bytes.fromhex(message_hash[2:]), + bytes.fromhex(SIG[2:]), "complete") + b.eth.recover_result = receipt + return {"version": 1, + "intent": {"source": {"chain": "sepolia", "asset": "usdc"}, + "destination": {"chain": "aleo-testnet", "asset": "usdcx"}, + "bridgeProtocol": "xreserve", "amount": "2", "recipient": ALEO_RECIPIENT, + "mintMode": "private"}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"transactionId": "0x" + "22" * 32}} + + +# ── surface ─────────────────────────────────────────────────────────────────── + +def test_tool_surface_and_schemas(): + tools = bridge_tools() + assert {t["name"] for t in tools} == READS | WRITES + assert {t["name"] for t in bridge_tools(include_writes=False)} == READS + for t in tools: + assert t["description"] and t["input_schema"]["type"] == "object" + json.dumps(t) + if t["name"] in WRITES: + assert t["input_schema"]["properties"]["confirm"]["type"] == "boolean" + assert "confirm" in t["description"].lower() + execute = next(t for t in tools if t["name"] == "bridge_execute") + assert set(execute["input_schema"]["required"]) == {"source", "destination", "amount", "recipient"} + assert "plan" not in execute["input_schema"]["properties"] # agents never carry plans + quote = next(t for t in tools if t["name"] == "bridge_quote") + assert quote["input_schema"]["properties"]["mint_mode"]["enum"] == ["public", "record", "private"] + assert "private key" not in json.dumps(tools).lower() + + +def test_private_mint_descriptions_state_the_nonce_requirement(): + tools = {t["name"]: t for t in bridge_tools()} + for name in ("bridge_quote", "bridge_execute", "bridge_complete"): + assert "secret_nonce" in tools[name]["input_schema"]["properties"] + for name in ("bridge_execute", "bridge_complete"): + description = tools[name]["input_schema"]["properties"]["secret_nonce"]["description"].lower() + assert "required" in description and "0scalar" not in description + + +# ── _serialize ──────────────────────────────────────────────────────────────── + +def test_serialize(): + fee = Fee(kind="protocol", chain_id="aleo", asset_id="aleo/aleo", amount="8.174147", estimated=True) + out = _serialize({"fee": fee, "raw": b"\x01\xff", "status": Status.COMPLETED, "n": 10**20, "t": (1, 2), "none": None}) + assert out == {"fee": {"kind": "protocol", "chain_id": "aleo", "asset_id": "aleo/aleo", "amount": "8.174147", + "estimated": True}, "raw": "0x01ff", "status": "COMPLETED", "n": 10**20, "t": [1, 2], "none": None} + json.dumps(out) + + +def test_serialize_adds_the_xreserve_max_fee_entry(): + b = FakeBridge() + quote = dispatch_tool(b, "bridge_quote", QUOTE_ARGS) + assert quote["fees"] == [{"kind": "protocol", "chain_id": "ethereum", "asset_id": "ethereum/usdc", + "amount": "0.1", "estimated": True, "label": "xReserve max fee"}] + # and the same entry appears when _serialize is handed the quote object on its own + from aleo_bridge import lifecycle + raw = lifecycle.quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT) + assert _serialize(raw)["fees"][-1]["label"] == "xReserve max fee" + + +def test_serialize_drops_secret_and_bulky_receipt_state(): + receipt = Receipt(id="0x01", protocol="xreserve", status=Status.ATTESTATION_PENDING, + protocol_state={"routeId": "r", "payload": "0x" + "ee" * 305, "attestation": "0x11", + "secretNonce": NONCE, "preparedTransaction": "{...}", "mintMode": "private"}) + out = _serialize(receipt) + assert out["protocol_state"] == {"routeId": "r", "mintMode": "private"} + assert NONCE not in json.dumps(out) + + +# ── reads ───────────────────────────────────────────────────────────────────── + +def test_read_tools(): + b = FakeBridge() + b.public_balances["aleo/usdcx"] = 5_000_000 + status = dispatch_tool(b, "bridge_status", {}) + assert status["environment"] == "mainnet" and status["chains"][0]["balances"] == {"aleo/usdcx": 5_000_000} + assets = dispatch_tool(b, "bridge_list_assets", {"chain": "aleo"}) + assert {a["symbol"] for a in assets} >= {"USDCx", "ETH", "WBTC", "USDT", "SOL"} and all(a["chain_id"] == "aleo" for a in assets) + routes = dispatch_tool(b, "bridge_list_routes", {"protocol": "xreserve"}) + assert routes and all(r["protocol"] == "xreserve" and r["availability"] == "active" for r in routes) + all_routes = dispatch_tool(b, "bridge_list_routes", {"include_unavailable": True}) + assert any(r["availability"] == "metadata-required" for r in all_routes) + q = dispatch_tool(b, "bridge_quote", {**QUOTE_ARGS, "mint_mode": "private", "secret_nonce": NONCE}) + assert q["kind"] == "evm-xreserve" and q["plan"]["route_id"] == "xreserve:ethereum/usdc->aleo/usdcx" + assert q["plan"]["amount"] == "2" and q["hook_data"].startswith("0x") + json.dumps(q) + assert NONCE not in json.dumps(q) + assert b.events == [] # nothing signed + + +# ── writes ──────────────────────────────────────────────────────────────────── + +def test_execute_requires_confirm_and_requotes_internally(): + b = FakeBridge() + out = dispatch_tool(b, "bridge_execute", QUOTE_ARGS) + assert out["confirmation_required"] is True and out["how_to_confirm"] == "re-call with confirm=true" + assert out["quote"]["kind"] == "evm-xreserve" and out["quote"]["plan"]["amount"] == "2" + assert [c[0] for c in b.calls] == ["eth.quote_deposit_usdc"] and b.events == [] + b.calls.clear() + out = dispatch_tool(b, "bridge_execute", {**QUOTE_ARGS, "confirm": True}) + assert [c[0] for c in b.calls] == ["eth.quote_deposit_usdc", "eth.deposit_usdc"] + assert out["progress"]["next"] == "wait" and out["progress"]["receipt"]["status"] == "ATTESTATION_PENDING" + assert out["checkpoint"]["version"] == 1 and out["checkpoint"]["route"]["id"] == "xreserve:ethereum/usdc->aleo/usdcx" + assert "secretNonce" not in json.dumps(out) and "payload" not in json.dumps(out["checkpoint"]) + json.dumps(out) + + +def test_get_progress_and_pending(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + out = dispatch_tool(b, "bridge_execute", {"source": "aleo/eth", "destination": "ethereum/eth", + "amount": "0.000000000000000001", "recipient": EVM_ADDRESS, + "gas_payment_microcredits": 1, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" + recovered = dispatch_tool(b, "bridge_get_progress", {"checkpoint": out["checkpoint"]}) + assert recovered["progress"]["next"] == "wait" + assert recovered["progress"]["receipt"]["source_tx_id"] == "at1fake1" + # a read hands back a checkpoint too: the model never has to keep the one execute returned + assert recovered["checkpoint"]["receiptId"] == "at1fake1" + assert recovered["checkpoint"]["route"]["id"] == out["checkpoint"]["route"]["id"] + assert b.submitted.count(b.submitted[0]) == 1 # recover never rebroadcasts + pending = dispatch_tool(b, "bridge_pending", {}) + assert [p["progress"]["receipt"]["id"] for p in pending] == ["at1fake1"] + assert [p["checkpoint"] for p in pending] == [cp.to_dict() for cp in store.list()] + json.dumps(pending) + + +def test_pending_hands_back_the_stored_checkpoint_that_restarts_the_flow(tmp_path): + """The restart path: a fresh process lists pending transfers and resumes one from the + checkpoint the listing echoed — never a checkpoint re-derived from a recovered receipt.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + cp, serialized = _aleo_out_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 1 and entries[0]["progress"]["next"] == "resume" + assert entries[0]["checkpoint"] == Checkpoint.from_dict(cp).to_dict() # stored form, verbatim + out = dispatch_tool(b, "bridge_resume", {"checkpoint": entries[0]["checkpoint"], "confirm": True}) + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" and b.aleo.submitted == [serialized] + + +def test_pending_checkpoint_completes_a_mint_whose_offline_progress_still_says_wait(tmp_path): + """``bridge_pending`` is offline, so its progress can lag the chain (here: 'wait' while Circle + has in fact attested). The echoed checkpoint is still exactly what ``bridge_complete`` takes.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(environment="testnet", checkpoints=store) + cp = _inbound_private_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 1 and entries[0]["progress"]["next"] == "wait" # offline view + out = dispatch_tool(b, "bridge_complete", {"checkpoint": entries[0]["checkpoint"], + "secret_nonce": NONCE, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "DESTINATION_CONFIRMING" + assert NONCE not in json.dumps(out) + + +def test_pending_reports_a_malformed_record_instead_of_collapsing_the_list(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + cp, _ = _aleo_out_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + (tmp_path / "at1broken.json").write_text(json.dumps( + {"version": 1, "receiptId": "at1broken", "intent": {}, "route": {"id": "x", "registryVersion": "y"}}), + encoding="utf-8") + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 2 + healthy = [e for e in entries if "progress" in e] + broken = [e for e in entries if "error" in e] + assert len(healthy) == 1 and healthy[0]["checkpoint"] == Checkpoint.from_dict(cp).to_dict() + assert len(broken) == 1 and broken[0]["checkpoint_id"] == "at1broken" + assert broken[0]["error_type"] == "CheckpointInvalidError" and broken[0]["error"] + assert "progress" not in broken[0] + json.dumps(entries) + + +def test_checkpoint_property_names_exactly_the_tools_that_return_one(): + tools = {t["name"]: t for t in bridge_tools()} + description = tools["bridge_get_progress"]["input_schema"]["properties"]["checkpoint"]["description"] + returns_one = {"bridge_execute", "bridge_resume", "bridge_complete", "bridge_get_progress", "bridge_pending"} + assert {name for name in tools if name in description} == returns_one + + +def test_resume_and_complete_gates(): + b = FakeBridge(ethereum=False) + cp, serialized = _aleo_out_checkpoint(b) + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp}) + assert out["confirmation_required"] is True and out["progress"]["next"] == "resume" and b.aleo.submitted == [] + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp, "confirm": True}) + # a resumed Aleo leg rebroadcasts the checkpointed bytes through the network, never re-proving + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" and b.aleo.submitted == [serialized] + + b2 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b2) + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE}) + assert out["confirmation_required"] is True and out["progress"]["next"] == "complete" and b2.events == [] + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "DESTINATION_CONFIRMING" + assert NONCE not in json.dumps(out) + + +def test_shield_unshield_gates(): + b = FakeBridge() + out = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount": "0.000000000000000001"}) + assert out["confirmation_required"] is True and out["call"]["function"] == "shield" and b.events == [] + out = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount": "0.000000000000000001", "confirm": True}) + assert out["direction"] == "shield" and b.events[-1][0] == "delegate" + out = dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, "confirm": True}) + assert out["direction"] == "unshield" and out["amount_atomic"] == 1 + + +def test_privacy_previews_never_echo_a_record(tmp_path): + """An unshield call's inputs carry the selected record's PLAINTEXT — the private balance + itself. The preview an agent shows a user must summarize it, never echo it.""" + b = FakeBridge() + out = dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1}) + blob = json.dumps(out) + assert out["confirmation_required"] is True + assert out["call"] == {"program": "arc20_eth.aleo", "function": "unshield", + "inputs": ["", "1u128"]} # amount literal kept + assert RECORD_PLAINTEXT not in blob and "_nonce" not in blob and "owner:" not in blob + # shielding has no record to leak: its literals pass through untouched + shield = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1}) + assert shield["call"]["inputs"] == ["1u128"] + + +@pytest.mark.parametrize("value", [ + RECORD_PLAINTEXT, + "{ owner: aleo1abc.private, microcredits: 1500000u64.private }", + "record1qyqsqpe2szk2wwwq56akkwx586hkndl3r8vzdwve32lm7elvphh37rsyqyxx66trwfhkxun9v35hguerqqpqzq" + "8tc0y3cc45vqs0vzcqmqxqwqsy3y6qw6w4vdkvq3qsqrqwqsyq", + "at1abcdefghijklmnop.record", +]) +def test_summarizer_redacts_record_shaped_inputs(value): + assert _summarize_input(value) == "" + + +@pytest.mark.parametrize("value", ["1u128", ALEO_RECIPIENT, "arc20_eth.aleo", "transfer_private_to_public", + "{ siblings: [ 0field ], leaf_index: 0u32 }", "true", "0u64"]) +def test_summarizer_keeps_public_literals(value): + assert _summarize_input(value) == value + + +def test_bridge_tools_returns_deep_copies(): + first = bridge_tools() + first[0]["description"] = "mutated" + first[0]["input_schema"]["properties"]["injected"] = {"type": "string"} + quote = next(t for t in first if t["name"] == "bridge_quote") + quote["input_schema"]["required"].append("injected") + + second = bridge_tools() + assert second[0]["description"] != "mutated" + assert "injected" not in second[0]["input_schema"]["properties"] + fresh_quote = next(t for t in second if t["name"] == "bridge_quote") + assert fresh_quote["input_schema"]["required"] == ["source", "destination", "amount", "recipient"] + assert fresh_quote["input_schema"]["properties"] is not quote["input_schema"]["properties"] + + +def test_unknown_tool(): + with pytest.raises(ValueError, match="Unknown bridge tool"): + dispatch_tool(FakeBridge(), "nope", {}) + + +# ── controller rulings ──────────────────────────────────────────────────────── + +def test_private_mint_never_defaults_the_secret_nonce(): + b = FakeBridge() + private = {**QUOTE_ARGS, "mint_mode": "private"} + for tool in ("bridge_quote", "bridge_execute"): + out = dispatch_tool(b, tool, dict(private)) + assert "secret_nonce" in out["error"] and out["how_to_fix"] + assert "confirmation_required" not in out and "quote" not in out + out = dispatch_tool(b, "bridge_execute", {**private, "confirm": True}) + assert "secret_nonce" in out["error"] + assert b.calls == [] and b.events == [] # nothing quoted, nothing moved + # a public mint still defaults quietly + assert dispatch_tool(b, "bridge_quote", QUOTE_ARGS)["kind"] == "evm-xreserve" + + b2 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b2) + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "confirm": True}) + assert "secret_nonce" in out["error"] and out["how_to_fix"] and b2.events == [] + + +def test_no_write_tool_ever_echoes_the_secret_nonce(): + """Every write tool, with and without confirm: the nonce is not in the JSON result.""" + results = [] + + b = FakeBridge() + private = {**QUOTE_ARGS, "mint_mode": "private", "secret_nonce": NONCE} + results.append(dispatch_tool(b, "bridge_execute", dict(private))) + results.append(dispatch_tool(b, "bridge_execute", {**private, "confirm": True})) + results.append(dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE})) + results.append(dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE, "confirm": True})) + results.append(dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE})) + results.append(dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE, "confirm": True})) + + b2 = FakeBridge(ethereum=False) + cp, _ = _aleo_out_checkpoint(b2) + results.append(dispatch_tool(b2, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE})) + results.append(dispatch_tool(b2, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True})) + + b3 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b3) + results.append(dispatch_tool(b3, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE})) + results.append(dispatch_tool(b3, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE, "confirm": True})) + + assert len(results) == 10 + for result in results: + blob = json.dumps(result) + assert NONCE not in blob and "secretNonce" not in blob and "secret_nonce" not in blob + + +def test_unconfigured_chain_returns_a_structured_error(): + b = FakeBridge(ethereum=False) # no EVM connection + for tool, args in (("bridge_quote", QUOTE_ARGS), ("bridge_execute", {**QUOTE_ARGS, "confirm": True})): + out = dispatch_tool(b, tool, dict(args)) + assert out["error"] and out["error_type"] in {"ConfigurationError", "BridgeError"} + assert "EVM_PRIVATE_KEY" in out["how_to_fix"] and "ETHEREUM_RPC_URL" in out["how_to_fix"] + assert "next" not in out + assert b.calls == [] and b.events == [] + out = dispatch_tool(FakeBridge(solana=False), "bridge_quote", + {"source": "solana/sol", "destination": "aleo/sol", "amount": "0.1", + "recipient": ALEO_RECIPIENT}) + assert "SOLANA_PRIVATE_KEY" in out["how_to_fix"] + + +def test_bridge_error_from_a_read_is_structured_too(): + out = dispatch_tool(FakeBridge(), "bridge_quote", {**QUOTE_ARGS, "amount": "0"}) + assert out["error"] and out["error_type"] == "InvalidAmountError" + + +def test_ambiguous_send_surfaces_recover_guidance_and_the_checkpoint(): + b = FakeBridge() + route_id = "xreserve:ethereum/usdc->aleo/usdcx" + approval = Receipt(id="0x" + "11" * 32, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM_ADDRESS}) + b.eth.intermediates = [approval] + b.eth.send_error = BridgeError("Ethereum deposit 0x" + "bb" * 32 + " may already be broadcast: " + "the RPC response was lost") + out = dispatch_tool(b, "bridge_execute", {**QUOTE_ARGS, "confirm": True}) + assert out["error_type"] == "BridgeError" and "may already be broadcast" in out["error"] + assert out["next"] == "recover" and "bridge_get_progress" in out["how_to_fix"] + assert out["checkpoint"]["receiptId"] == "0x" + "11" * 32 + assert out["checkpoint"]["route"]["id"] == route_id + json.dumps(out) + + +def test_resume_write_error_surfaces_recover_guidance_and_the_checkpoint(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, mint_mode="private") + approval = "0x" + "11" * 32 + receipt = Receipt(id=approval, protocol="xreserve", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [approval], + "sourceSender": EVM_ADDRESS, + "hookData": "0x" + b.eth.hook_data.hex()}) + b.eth.recover_result = receipt # history still has no deposit + b.eth.intermediates = [receipt] # re-checkpointed before the deposit goes out + b.eth.send_error = BridgeError("Ethereum deposit may already be broadcast: the RPC response was lost") + cp = create_checkpoint(plan, receipt, b.registry).to_dict() + + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True}) + assert out["error_type"] == "BridgeError" and "may already be broadcast" in out["error"] + assert out["next"] == "recover" and "bridge_get_progress" in out["how_to_fix"] + assert out["checkpoint"]["receiptId"] == approval and out["checkpoint"]["route"]["id"] == plan.route_id + assert NONCE not in json.dumps(out) + + +def test_complete_write_error_surfaces_recover_guidance_and_the_prepared_checkpoint(): + b = FakeBridge(environment="testnet") + cp = _inbound_private_checkpoint(b) + b.submit_error = BridgeError("Aleo mint at1fake1 may already be broadcast: the node answer was lost") + + out = dispatch_tool(b, "bridge_complete", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True}) + assert out["error_type"] == "BridgeError" and out["next"] == "recover" + assert "bridge_get_progress" in out["how_to_fix"] + # the pre-broadcast checkpoint made it out: the proved mint can be rebroadcast, never re-proved + assert out["checkpoint"]["destination"]["preparedTransaction"]["transactionId"] == "at1fake1" + assert NONCE not in json.dumps(out) diff --git a/bridge-sdk/tests/test_base58.py b/bridge-sdk/tests/test_base58.py new file mode 100644 index 0000000..427336f --- /dev/null +++ b/bridge-sdk/tests/test_base58.py @@ -0,0 +1,29 @@ +import pytest + +from aleo_bridge._base58 import b58decode, b58encode + +SYSTEM_PROGRAM = "11111111111111111111111111111111" +WARP_PROGRAM = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + + +def test_decode_known_solana_keys(): + assert b58decode(SYSTEM_PROGRAM) == bytes(32) + raw = b58decode(WARP_PROGRAM) + assert len(raw) == 32 + # First four bytes match the aleoRemoteRouterRecipient literal of hyperlane:aleo/sol->solana/sol + assert list(raw[:4]) == [112, 4, 72, 22] + assert list(raw[-4:]) == [7, 6, 211, 122] + + +def test_round_trip_and_leading_zero_handling(): + for value in (bytes(32), b"\x00\x00\x01", b"\x01\x00\x00", bytes(range(1, 33)), b""): + assert b58decode(b58encode(value)) == value + assert b58encode(b"") == "" + assert b58encode(bytes(32)) == SYSTEM_PROGRAM + assert b58encode(b58decode(WARP_PROGRAM)) == WARP_PROGRAM + + +def test_decode_rejects_bad_characters(): + for bad in ("not-base58!", "0OIl", "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 "): + with pytest.raises(ValueError): + b58decode(bad) diff --git a/bridge-sdk/tests/test_bridge_eth_wiring.py b/bridge-sdk/tests/test_bridge_eth_wiring.py new file mode 100644 index 0000000..3582109 --- /dev/null +++ b/bridge-sdk/tests/test_bridge_eth_wiring.py @@ -0,0 +1,105 @@ +import pytest +from eth_account import Account +from web3 import HTTPProvider, Web3 + +from aleo_bridge import Bridge, Ethereum, EthModule, EvmCall +from aleo_bridge.errors import ChainMismatchError, ConfigurationError +from aleo_bridge.types import BridgeStatus, ChainStatus +from tests.fakes.fake_web3 import fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +WBTC, USDC, USDT = ("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xdAC17F958D2ee523a2206206994597C13D831ec7") + + +def test_package_exports(): + assert EthModule is not None and EvmCall is not None and Ethereum is not None + + +def test_eth_property_requires_a_connection(): + bridge = make_bridge() + assert bridge.ethereum is None + with pytest.raises(ConfigurationError, + match=r"Pass ethereum=Ethereum\(\.\.\.\) to Bridge\(\.\.\.\) or set EVM_PRIVATE_KEY \+ ETHEREUM_RPC_URL"): + bridge.eth + + +def test_eth_property_is_cached_module_bound_to_connection(): + conn = Ethereum(w3=fake_web3(), private_key=KEY) + bridge = make_bridge(ethereum=conn) + assert bridge.ethereum is conn and isinstance(bridge.eth, EthModule) + assert bridge.eth is bridge.eth and bridge.eth.conn is conn and bridge.eth.chain.id == "ethereum" + assert make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth.chain.id == "sepolia" + + +def test_bare_web3_is_wrapped_read_only_unless_default_account(): + w3 = fake_web3() + bridge = make_bridge(ethereum=w3) + assert isinstance(bridge.ethereum, Ethereum) and bridge.ethereum.w3 is w3 and not bridge.ethereum.can_sign + w3.eth.default_account = ACCT.address + assert make_bridge(ethereum=w3).ethereum.address == ACCT.address + with pytest.raises(ConfigurationError, match="Ethereum connection or a web3.Web3"): + make_bridge(ethereum="https://not-a-client") + + +def test_from_env_requires_both_evm_variables(monkeypatch): + from aleo import PrivateKey + + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", str(PrivateKey.random())) + monkeypatch.delenv("SOLANA_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_CHECKPOINT_DIR", raising=False) + monkeypatch.delenv("BRIDGE_EVM_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", raising=False) + monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) + monkeypatch.delenv("ETHEREUM_RPC_URL", raising=False) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Bridge.from_env() + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.setenv("ETHEREUM_RPC_URL", "http://127.0.0.1:1") + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Bridge.from_env() + monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) + bridge = Bridge.from_env() + assert bridge.ethereum.address == ACCT.address and isinstance(bridge.ethereum.w3.provider, HTTPProvider) + assert bridge.ethereum.w3.provider.endpoint_uri == "http://127.0.0.1:1" + override = Ethereum(w3=fake_web3()) + assert Bridge.from_env(ethereum=override).ethereum is override + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.delenv("ETHEREUM_RPC_URL") + assert Bridge.from_env().ethereum is None + + +def test_chain_status_reads_native_and_erc20_balances(): + w3 = fake_web3(eth_balances={ACCT.address: 5}, token_balances={(WBTC, ACCT.address): 7, (USDC, ACCT.address): 2_000_000}) + eth = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)).eth + status = eth.chain_status() + assert isinstance(status, ChainStatus) and status.chain_id == "ethereum" and status.address == ACCT.address and status.can_sign + assert status.balances == {"ethereum/eth": 5, "ethereum/usdc": 2_000_000, "ethereum/wbtc": 7, "ethereum/usdt": 0} + read_only = make_bridge(ethereum=Ethereum(w3=fake_web3())).eth.chain_status() + assert read_only.address is None and not read_only.can_sign and read_only.balances == {} + + +def test_chain_status_asserts_the_connected_chain(monkeypatch): + """A Web3 pointed at the wrong network must fail chain_status() (and therefore Bridge.status()) + with ChainMismatchError before any balance is read.""" + w3 = fake_web3(chain_id=999) + bridge = make_bridge(ethereum=Ethereum(w3=w3)) + with pytest.raises(ChainMismatchError, match="expected 1"): + bridge.eth.chain_status() + assert "eth_getBalance" not in w3.provider.methods and "eth_call" not in w3.provider.methods + aleo_status = ChainStatus(chain_id="aleo", address=None, can_sign=False, balances={}) + monkeypatch.setattr(Bridge, "_aleo_chain_status", lambda self: aleo_status) + with pytest.raises(ChainMismatchError, match="expected 1"): + bridge.status() + + +def test_bridge_status_includes_evm_chain(monkeypatch): + aleo_status = ChainStatus(chain_id="aleo", address="aleo1" + "q" * 58, can_sign=True, balances={}) + monkeypatch.setattr(Bridge, "_aleo_chain_status", lambda self: aleo_status) + w3 = fake_web3(eth_balances={ACCT.address: 5}) + bridge = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)) + status = bridge.status() + assert isinstance(status, BridgeStatus) and [c.chain_id for c in status.chains] == ["aleo", "ethereum"] + assert status.chains[1].balances["ethereum/eth"] == 5 + assert [c.chain_id for c in make_bridge().status().chains] == ["aleo"] diff --git a/bridge-sdk/tests/test_calls.py b/bridge-sdk/tests/test_calls.py new file mode 100644 index 0000000..d8a21c8 --- /dev/null +++ b/bridge-sdk/tests/test_calls.py @@ -0,0 +1,133 @@ +import json + +import pytest + +from aleo_bridge._calls import AleoCall, extract_tx_id, is_duplicate_submission, payload_transitions, root_outputs +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.types import PreparedTx + +PROGRAM, FN = "shielded_usdcx_wrapper.aleo", "private_burn" + + +def _call(fake_aleo, imports=None) -> AleoCall: + bound = fake_aleo.programs.get(PROGRAM).functions[FN]("a", "2500000u128") + return AleoCall(fake_aleo, bound, lambda tx_id, outs: (tx_id, outs), imports=imports) + + +def test_helpers(): + assert extract_tx_id("at1abc") == "at1abc" + assert extract_tx_id({"transaction": {"id": "at1x"}}) == "at1x" + assert extract_tx_id({"transaction_id": "at1y"}) == "at1y" + with pytest.raises(ValueError): + extract_tx_id({"nope": 1}) + decoded = [{"program": "tok.aleo", "function": "transfer", "outputs": [{"value": "999field"}]}, + {"program": "p.aleo", "function": "f", "outputs": [{"value": "77field"}]}, + {"program": "p.aleo", "function": "f", "outputs": ["78field"]}] + assert root_outputs(decoded, "p.aleo", "f") == ["78field"] # LAST matching transition is the root + assert root_outputs(decoded, "p.aleo", "g") == [] + assert payload_transitions({"transaction_id": "x"}) is None + assert payload_transitions({"transaction": {"execution": {"transitions": [{"program": "p", "function": "f", "outputs": []}]}}}) == \ + [{"program": "p", "function": "f", "outputs": []}] + assert is_duplicate_submission(RuntimeError("Transaction 'at1x' already exists in the ledger")) + assert not is_duplicate_submission(RuntimeError("duplicate transaction")) # real double-spend, must propagate + assert not is_duplicate_submission(RuntimeError("duplicate serial number")) + assert not is_duplicate_submission(RuntimeError("insufficient fee")) + + +def test_attributes_and_simulate(fake_aleo): + call = _call(fake_aleo) + assert (call.program_id, call.function_name, call.inputs) == (PROGRAM, FN, ["a", "2500000u128"]) + assert call.simulate() == "simulated" and fake_aleo.simulated == [(PROGRAM, FN)] + assert fake_aleo.submitted == [] and fake_aleo.delegated == [] + + +def test_repr_never_leaks_input_literals(fake_aleo): + # private_burn input 0 is a USDCx record plaintext; a secret nonce (private_mint input 3) is + # just as sensitive — repr() must never print .inputs, only a count. + record = "{ owner: aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px.private, amount: 5000000u128.private, _nonce: 7group.public }" + secret_nonce = "7scalar" + bound = fake_aleo.programs.get(PROGRAM).functions[FN](record, "2500000u128", "0u32", "[0field]", secret_nonce) + call = AleoCall(fake_aleo, bound, lambda tx_id, outs: (tx_id, outs)) + text = repr(call) + assert text == f"AleoCall({PROGRAM}/{FN}, inputs=5 literals)" + assert record not in text and secret_nonce not in text + assert call.inputs == [record, "2500000u128", "0u32", "[0field]", secret_nonce] # accessor still exposes them + + +def test_prove_returns_prepared_tx_without_broadcast(fake_aleo): + prepared = _call(fake_aleo).prove(priority_fee=5) + assert isinstance(prepared, PreparedTx) and prepared.transaction_id == "at1built" + assert json.loads(prepared.serialized)["id"] == "at1built" + assert fake_aleo.fee_kwargs == [{"priority_fee": 5}] and fake_aleo.submitted == [] + + +def test_transact_harvests_root_outputs_then_broadcasts(fake_aleo): + tx_id, outputs = _call(fake_aleo).transact() + assert tx_id == "at1built" and outputs == ["77field"] + assert len(fake_aleo.submitted) == 1 and json.loads(fake_aleo.submitted[0])["id"] == "at1built" + + +def test_delegate_broadcast_uses_payload_transitions(fake_aleo): + tx_id, outputs = _call(fake_aleo).delegate(wait=False) + assert (tx_id, outputs) == ("at1delegated", ["77field"]) + assert fake_aleo.delegated[0]["broadcast"] is True and fake_aleo.waited == [] + _call(fake_aleo).delegate(wait_timeout=7.0) + assert fake_aleo.waited == [("at1delegated", 7.0)] + + +def test_delegate_falls_back_to_fetching_when_payload_is_id_only(fake_aleo): + fake_aleo.delegate_returns_id_only = True + bound = fake_aleo.programs.get("hyp_warp_token_wbtc_v2.aleo").functions["transfer_remote"]("x") + tx_id, outputs = AleoCall(fake_aleo, bound, lambda t, o: (t, o)).delegate(wait=False) + assert tx_id == "at1delegated" and outputs == ["99field"] # from get_transaction_object + assert fake_aleo.waited == [("at1delegated", 180.0)] # must wait before fetching + + +def test_delegate_prepared_and_submit_prepared(fake_aleo): + call = _call(fake_aleo) + prepared = call.delegate_prepared() + assert fake_aleo.delegated[-1]["broadcast"] is False and fake_aleo.submitted == [] + assert prepared.transaction_id == "at1delegated" and json.loads(prepared.serialized)["execution"]["transitions"] + tx_id, outputs = call.submit_prepared(prepared, wait=False) + assert (tx_id, outputs) == ("at1delegated", ["77field"]) and fake_aleo.submitted == [prepared.serialized] + + +def test_delegate_without_broadcast_is_prepare_then_submit(fake_aleo): + tx_id, outputs = _call(fake_aleo).delegate(broadcast=False, wait_timeout=9.0) + assert tx_id == "at1delegated" and outputs == ["77field"] + assert fake_aleo.delegated[-1]["broadcast"] is False and len(fake_aleo.submitted) == 1 + assert fake_aleo.waited == [("at1delegated", 9.0)] + + +def test_submit_prepared_treats_duplicate_as_success(fake_aleo): + call = _call(fake_aleo) + prepared = call.delegate_prepared() + fake_aleo.duplicate_on_submit = True + tx_id, outputs = call.submit_prepared(prepared) + assert tx_id == "at1delegated" and outputs == ["77field"] + + +def test_submit_prepared_confirmation_timeout_propagates_after_broadcast(fake_aleo): + from aleo.facade.errors import TransactionConfirmationTimeout + + call = _call(fake_aleo) + prepared = call.delegate_prepared() + fake_aleo.wait_raises = True + with pytest.raises(TransactionConfirmationTimeout): + call.submit_prepared(prepared) + assert fake_aleo.submitted == [prepared.serialized] # broadcast already happened + + +def test_delegate_prepared_requires_transaction_payload(fake_aleo): + fake_aleo.delegate_returns_id_only = True + with pytest.raises(ConfigurationError, match="did not return the transaction"): + _call(fake_aleo).delegate_prepared() + + +def test_imports_are_registered_once_before_first_verb(fake_aleo): + call = _call(fake_aleo, imports={"token_registry.aleo": "program token_registry.aleo;", PROGRAM: "program shielded_usdcx_wrapper.aleo;"}) + assert fake_aleo.registered == [] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", PROGRAM] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", PROGRAM] # idempotent diff --git a/bridge-sdk/tests/test_checkpoint.py b/bridge-sdk/tests/test_checkpoint.py new file mode 100644 index 0000000..edce322 --- /dev/null +++ b/bridge-sdk/tests/test_checkpoint.py @@ -0,0 +1,219 @@ +"""Tests for the version-1 checkpoint allowlist and FileCheckpointStore (brief §2.10). + +``lifecycle.prepare`` does not exist yet (plan 4 task order runs checkpoint.py before +lifecycle.py, since EvmCall.send/SolCall.send depend on create_checkpoint) — plans are +built by hand from the real DEFAULT_REGISTRY instead of going through prepare(). +""" +import json +import os +import stat +import time +from decimal import Decimal + +import pytest + +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint +from aleo_bridge.errors import CheckpointInvalidError +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry +from aleo_bridge.types import Plan, Receipt, Status + +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +APPROVAL = "0x" + "11" * 32 +SOURCE = "0x" + "22" * 32 +EVM1 = "0x0000000000000000000000000000000000000001" + + +def _make_plan(registry: Registry, *, source, destination, amount, recipient, + sender=None, protocol=None, mint_mode="public") -> Plan: + route = registry.find_route(source, destination, protocol=protocol) + src = registry.asset(source) + dst = registry.asset(destination) + amount_atomic = int(Decimal(amount) * (10 ** src.decimals)) + return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, + environment=route.environment, source_asset_id=src.id, destination_asset_id=dst.id, + amount=amount, amount_atomic=amount_atomic, recipient=recipient, sender=sender, + mint_mode=mint_mode, steps=()) + + +def _plan(**kw): + base = dict(source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=RECIPIENT, mint_mode="private") + base.update(kw) + return _make_plan(DEFAULT_REGISTRY, **base) + + +def test_allowlist_persists_intent_and_ids_only(): + plan = _plan() + receipt = Receipt(id="at1destination", protocol="xreserve", status=Status.DESTINATION_CONFIRMING, + source_tx_id=SOURCE, destination_tx_id="at1destination", + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "payload": "0xdeadbeef", "messageHash": "0x" + "33" * 32, + "nonce": "0x" + "44" * 32, "attestation": "0x" + "55" * 65, + "amountAtomic": "2000000", "maxFeeAtomic": "100000", + "remoteRecipientBytes32": "0x" + "66" * 32, + "secretNonce": "7scalar"}) + cp = create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + assert cp.to_dict() == { + "version": 1, + "receiptId": "at1destination", + "intent": {"source": {"chain": "sepolia", "asset": "usdc"}, + "destination": {"chain": "aleo-testnet", "asset": "usdcx"}, + "bridgeProtocol": "xreserve", "amount": "2", "recipient": RECIPIENT, + "mintMode": "private"}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"approvalTransactionIds": [APPROVAL], "transactionId": SOURCE}, + "destination": {"transactionId": "at1destination"}, + } + text = cp.to_json() + for forbidden in ("payload", "messageHash", "nonce", "attestation", "secretNonce", + "amountAtomic", "maxFeeAtomic", "remoteRecipientBytes32", "0xdeadbeef"): + assert forbidden not in text + assert cp.id == "at1destination" + assert Checkpoint.from_json(text) == cp + + +def test_mint_mode_only_when_destination_is_an_aleo_program(): + outbound = _make_plan(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=EVM1) + cp = create_checkpoint(outbound, Receipt(id="at1x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1x", protocol_state={"routeId": outbound.route_id}), + DEFAULT_REGISTRY) + assert "mintMode" not in cp.intent and cp.intent["bridgeProtocol"] == "hyperlane" + assert cp.source == {"transactionId": "at1x"} and cp.destination is None + assert "destination" not in cp.to_dict() + + +def test_sender_from_plan_or_source_sender(): + plan = _plan(sender=EVM1) + receipt = Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": "0x0000000000000000000000000000000000000002"}) + assert create_checkpoint(plan, receipt, DEFAULT_REGISTRY).intent["sender"] == EVM1 + plan2 = _plan() + assert create_checkpoint(plan2, receipt.replace(protocol_state={**receipt.protocol_state, "routeId": plan2.route_id}), + DEFAULT_REGISTRY).intent["sender"] == "0x0000000000000000000000000000000000000002" + assert create_checkpoint(plan2, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=SOURCE, protocol_state={"routeId": plan2.route_id}), + DEFAULT_REGISTRY).intent.get("sender") is None + + +def test_solana_blockhash_pair_both_or_neither(): + plan = _make_plan(DEFAULT_REGISTRY, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=RECIPIENT, sender="11111111111111111111111111111111") + ok = Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhash": "recent", "lastValidBlockHeight": "123456789"}) + assert create_checkpoint(plan, ok, DEFAULT_REGISTRY).source == { + "transactionId": "sig", "blockhash": "recent", "lastValidBlockHeight": "123456789"} + with pytest.raises(CheckpointInvalidError, match="blockhash"): + create_checkpoint(plan, ok.replace(protocol_state={"routeId": plan.route_id, "blockhash": "recent"}), + DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="blockhash"): + create_checkpoint(plan, ok.replace(protocol_state={"routeId": plan.route_id, "blockhash": "recent", + "lastValidBlockHeight": "abc"}), DEFAULT_REGISTRY) + + +def test_delivery_verification_pair_both_or_neither(): + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/sol", destination="solana/sol", amount="0.000000001", + recipient="11111111111111111111111111111111") + receipt = Receipt(id="at1s", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="at1s", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + cp = create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + assert cp.delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + with pytest.raises(CheckpointInvalidError, match="verification"): + create_checkpoint(plan, receipt.replace(protocol_state={"routeId": plan.route_id, + "destinationBalanceBeforeAtomic": "100"}), + DEFAULT_REGISTRY) + + +def test_prepared_transactions_and_hook_data(): + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM1) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + cp = create_checkpoint(plan, Receipt(id="at1prepared", protocol="hyperlane", + status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, + "preparedTransaction": serialized}), + DEFAULT_REGISTRY) + assert cp.source == {"preparedTransaction": {"transactionId": "at1prepared", + "serializedTransaction": serialized}} + with pytest.raises(CheckpointInvalidError, match="prepared transaction"): + create_checkpoint(plan, Receipt(id="x", protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": ""}), + DEFAULT_REGISTRY) + inbound = _plan() + cp2 = create_checkpoint(inbound, Receipt(id="at1mint", protocol="xreserve", + status=Status.DESTINATION_ACTION_REQUIRED, source_tx_id=SOURCE, + protocol_state={"routeId": inbound.route_id, "hookData": "0x02" + "00" * 64, + "preparedDestinationTransaction": serialized}), + DEFAULT_REGISTRY) + assert cp2.source == {"transactionId": SOURCE, "hookData": "0x02" + "00" * 64} + assert cp2.destination == {"preparedTransaction": {"transactionId": "at1mint", + "serializedTransaction": serialized}} + + +def test_rejects_receipts_from_other_routes_and_bad_approvals(): + plan = _plan() + with pytest.raises(CheckpointInvalidError, match="does not match"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": "xreserve:wrong/route"}), DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="does not match"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id}), DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="approval"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [1, 2]}), + DEFAULT_REGISTRY) + + +def test_from_dict_accepts_veil_shaped_checkpoints_without_receipt_id(): + veil = {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": "0.000000000000000001", "recipient": EVM1}, + "route": {"id": "hyperlane:aleo/eth->ethereum/eth", "registryVersion": DEFAULT_REGISTRY.version}, + "source": {"preparedTransaction": {"transactionId": "at1prepared", "serializedTransaction": "{}"}}} + cp = Checkpoint.from_dict(veil) + assert cp.id == "at1prepared" and cp.version == 1 and cp.delivery_verification is None + assert Checkpoint.from_dict({**veil, "source": {"transactionId": "at1src"}}).id == "at1src" + assert Checkpoint.from_dict({**veil, "source": {"approvalTransactionIds": [APPROVAL]}}).id == APPROVAL + with pytest.raises(CheckpointInvalidError): + Checkpoint.from_dict({"version": 2, "intent": {}, "route": {}}) + with pytest.raises(CheckpointInvalidError): + Checkpoint.from_dict({**veil, "source": {}}) + + +def test_file_store_roundtrip_mode_and_atomic_rename(tmp_path): + store = FileCheckpointStore(tmp_path / "cps") + plan = _plan() + cp = create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=SOURCE, protocol_state={"routeId": plan.route_id}), + DEFAULT_REGISTRY) + store.save(cp) + files = list((tmp_path / "cps").iterdir()) + assert [f.name for f in files] == [f"{SOURCE}.json"] + assert stat.S_IMODE(os.stat(files[0]).st_mode) == 0o600 + assert not list((tmp_path / "cps").glob("*.tmp")) + assert store.load(SOURCE) == cp + assert store.load("missing") is None + assert store.list() == [cp] + store.delete(SOURCE) + store.delete(SOURCE) # idempotent + assert store.list() == [] and store.load(SOURCE) is None + + +def test_file_store_sanitizes_ids_and_orders_by_mtime(tmp_path): + store = FileCheckpointStore(str(tmp_path)) + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM1) + a = create_checkpoint(plan, Receipt(id="at1a", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1a", protocol_state={"routeId": plan.route_id}), DEFAULT_REGISTRY) + weird = create_checkpoint(plan, Receipt(id="../evil id", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="../evil id", protocol_state={"routeId": plan.route_id}), + DEFAULT_REGISTRY) + store.save(a) + time.sleep(0.01) # keep mtimes ordered on coarse filesystems + store.save(weird) + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == ["___evil_id.json", "at1a.json"] + assert [c.id for c in store.list()] == ["at1a", "../evil id"] # oldest first + assert store.load("../evil id") == weird diff --git a/bridge-sdk/tests/test_circle.py b/bridge-sdk/tests/test_circle.py new file mode 100644 index 0000000..585647b --- /dev/null +++ b/bridge-sdk/tests/test_circle.py @@ -0,0 +1,92 @@ +import requests +import pytest + +from aleo_bridge._keccak import keccak256 +from aleo_bridge.circle import CircleClient +from aleo_bridge.errors import AttestationError, ConfigurationError +from aleo_bridge.types import Attestation + +BASE = "https://xreserve-api.circle.com/v1/attestations" +PAYLOAD = bytes(305) +HASH = keccak256(PAYLOAD) +SIG = bytes.fromhex("11" * 65) + + +class _Response: + def __init__(self, status_code, body=None): + self.status_code, self._body = status_code, body + + def json(self): + return self._body + + +class _BadJsonResponse: + """A 200 whose body isn't valid JSON (``.json()`` raises, as ``requests`` does).""" + status_code = 200 + + def json(self): + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + +class _Session: + def __init__(self, *responses): + self._responses, self.urls = list(responses), [] + + def get(self, url, timeout=None): + self.urls.append((url, timeout)) + return self._responses.pop(0) + + +class _RaisingSession: + """A session whose ``get`` raises a transport-level ``requests`` exception.""" + def __init__(self, exc): + self._exc = exc + + def get(self, url, timeout=None): + raise self._exc + + +def _body(payload=PAYLOAD, signature=SIG, message_hash=HASH): + return {"attestation": {"payload": "0x" + payload.hex(), "attestation": "0x" + signature.hex(), "messageHash": "0x" + message_hash.hex()}} + + +def test_404_is_pending_none(): + session = _Session(_Response(404)) + assert CircleClient(BASE, session=session, timeout=9).get_attestation("0x" + HASH.hex()) is None + assert session.urls == [(f"{BASE}/0x{HASH.hex()}", 9)] + + +def test_complete_attestation_is_verified(): + att = CircleClient(BASE, session=_Session(_Response(200, _body()))).get_attestation(HASH.hex()) # bare hex accepted + assert att == Attestation(payload=PAYLOAD, message_hash=HASH, attestation=SIG, status="complete") + + +def test_rejects_http_errors_and_bad_bodies(): + with pytest.raises(AttestationError, match="HTTP 500"): + CircleClient(BASE, session=_Session(_Response(500))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="invalid response"): + CircleClient(BASE, session=_Session(_Response(200, {"attestation": {"payload": "zz"}}))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="invalid response"): + CircleClient(BASE, session=_Session(_Response(200, {}))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="different message hash"): + CircleClient(BASE, session=_Session(_Response(200, _body(message_hash=bytes(32))))).get_attestation("0x" + HASH.hex()) + other = bytes.fromhex("01" * 305) + with pytest.raises(AttestationError, match="does not match the requested message hash"): + CircleClient(BASE, session=_Session(_Response(200, _body(payload=other)))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="32-byte"): + CircleClient(BASE, session=_Session()).get_attestation("0x1234") + with pytest.raises(ConfigurationError, match="https"): + CircleClient("http://insecure.example") + + +def test_wraps_transport_and_json_failures(): + for exc in (requests.exceptions.ConnectionError("connection refused"), requests.exceptions.Timeout("timed out")): + session = _RaisingSession(exc) + with pytest.raises(AttestationError, match="Circle attester request failed") as excinfo: + CircleClient(BASE, session=session).get_attestation("0x" + HASH.hex()) + assert excinfo.value.__cause__ is exc + + session = _Session(_BadJsonResponse()) + with pytest.raises(AttestationError, match="Circle attester request failed") as excinfo: + CircleClient(BASE, session=session).get_attestation("0x" + HASH.hex()) + assert isinstance(excinfo.value.__cause__, ValueError) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py new file mode 100644 index 0000000..5a073a0 --- /dev/null +++ b/bridge-sdk/tests/test_client.py @@ -0,0 +1,231 @@ +import json + +import pytest +from eth_account import Account as EthAccount + +from aleo.facade.errors import ProgramNotFound + +from aleo_bridge import Bridge, __main__ as cli +from aleo_bridge._calls import AleoCall +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.eth import Ethereum, EthModule +from aleo_bridge.freezelist import FreezeList +from aleo_bridge.hyperlane import HyperlaneModule +from aleo_bridge.privacy import PrivacyModule +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry +from aleo_bridge.types import BridgeStatus +from aleo_bridge.xreserve import XReserveModule +from tests.conftest import SIGNER, FakeAleo, default_mappings +from tests.fakes.fake_web3 import fake_web3 + + +def test_construction_defaults_and_namespaces(fake_aleo): + bridge = Bridge(fake_aleo) + assert (bridge.environment, bridge.network, bridge.registry) == ("mainnet", "mainnet", DEFAULT_REGISTRY) + assert bridge.checkpoints is None and bridge.ethereum is None and bridge.solana is None and bridge.profile is None + assert isinstance(bridge.hyperlane, HyperlaneModule) and isinstance(bridge.xreserve, XReserveModule) + assert isinstance(bridge.freezelist, FreezeList) and isinstance(bridge.privacy, PrivacyModule) + assert bridge.aleo_chain().id == "aleo" and bridge.aleo_address() == SIGNER + assert Bridge(FakeAleo(network_name="testnet")).aleo_chain().id == "aleo-testnet" + + +def test_construction_errors(fake_aleo): + with pytest.raises(ConfigurationError, match="does not match"): + Bridge(fake_aleo, environment="testnet") + with pytest.raises(ConfigurationError, match="mainnet or testnet"): + Bridge(FakeAleo(network_name="devnet")) + empty = Registry(DEFAULT_REGISTRY.version, DEFAULT_REGISTRY.chains(environment="testnet"), + DEFAULT_REGISTRY.assets(environment="testnet"), DEFAULT_REGISTRY.routes(environment="testnet")) + with pytest.raises(ConfigurationError, match="no chains for mainnet"): + Bridge(fake_aleo, registry=empty) + with pytest.raises(ConfigurationError, match="default_account"): + Bridge(FakeAleo(default_account=False)).aleo_address() + + +def test_eth_property_wraps_connection_and_sol_property_now_wraps_a_bare_client(fake_aleo): + """Plan 3 (task 5) landed ``SolModule``, so ``bridge.sol`` on a configured connection now + succeeds instead of degrading to ``MissingExtraError`` (that fallback covered the window + before ``SolModule`` existed; ``sol.py``'s ``try/except ImportError`` around the import is + still exercised by ``test_sol_connection.py``'s no-solders scenarios).""" + bridge = Bridge(fake_aleo) + with pytest.raises(ConfigurationError, match="ethereum="): + bridge.eth + with pytest.raises(ConfigurationError, match="solana="): + bridge.sol + eth_module = Bridge(fake_aleo, ethereum=fake_web3()).eth # plan 2: real Ethereum wraps a bare Web3 + assert isinstance(eth_module, EthModule) + pytest.importorskip("solders") + from aleo_bridge.sol import SolModule + from tests.fakes.fake_solana import FakeSolanaClient + + sol_module = Bridge(fake_aleo, solana=FakeSolanaClient()).sol # plan 3: bare client is wrapped in Solana + assert isinstance(sol_module, SolModule) + + +def test_program_cache_mapping_value_and_call_registration(fake_aleo): + fake_aleo.imports = {"hyp_warp_token_wbtc_v2.aleo": ["token_registry.aleo", "hyp_mailbox.aleo"], "hyp_mailbox.aleo": ["token_registry.aleo"]} + bridge = Bridge(fake_aleo) + assert bridge.program("credits.aleo") is bridge.program("credits.aleo") and fake_aleo.fetched.count("credits.aleo") == 1 + assert bridge.mapping_value("credits.aleo", "account", SIGNER) == "2392443u64" + assert bridge.mapping_value("credits.aleo", "account", "aleo1nobody") is None + fake_aleo.mappings["credits.aleo"]["account"]["aleo1null"] = "null" + fake_aleo.mappings["credits.aleo"]["account"]["aleo1quoted"] = '"7u64"' + assert bridge.mapping_value("credits.aleo", "account", "aleo1null") is None + assert bridge.mapping_value("credits.aleo", "account", "aleo1quoted") == "7u64" + call = bridge._call("hyp_warp_token_wbtc_v2.aleo", "transfer_remote", ["1u128"], lambda tx, outs: tx) + assert isinstance(call, AleoCall) and fake_aleo.registered == [] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", "hyp_mailbox.aleo", "hyp_warp_token_wbtc_v2.aleo"] # dependencies first, root last + + +def test_mapping_value_returns_none_for_missing_program(fake_aleo): + fake_aleo.missing_programs = {"missing.aleo"} + bridge = Bridge(fake_aleo) + assert bridge.mapping_value("missing.aleo", "balances", SIGNER) is None + with pytest.raises(ProgramNotFound): + bridge.program("missing.aleo") + + +def test_amount_helpers_and_privacy_delegation(fake_aleo): + from aleo_bridge.freezelist import EMPTY_TREE_ROOT + + # unshield()'s default (unsupplied) merkle_proof= resolves through freezelist.exclusion_proof(), + # which now requires a readable on-chain root (item 4) — seed the empty-list root. + fake_aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} + bridge = Bridge(fake_aleo) + assert bridge.to_atomic("0.001", "aleo/wbtc") == 100_000 and bridge.from_atomic(100_000, ("aleo", "wbtc")) == "0.001" + assert bridge.to_atomic("1", DEFAULT_REGISTRY.asset("ethereum/usdc")) == 1_000_000 + assert bridge.shield("aleo/eth", amount="1").function_name == "shield" + assert bridge.unshield("aleo/usdcx", amount="2.5").function_name == "transfer_private_to_public" + + +def test_status_reads_every_aleo_asset_balance(fake_aleo): + status = Bridge(fake_aleo).status() + assert isinstance(status, BridgeStatus) and status.environment == "mainnet" and status.registry_version == DEFAULT_REGISTRY.version + assert status.pending == [] and len(status.chains) == 1 + chain = status.chains[0] + assert (chain.chain_id, chain.address, chain.can_sign) == ("aleo", SIGNER, True) + assert chain.balances == {"aleo/aleo": 2392443, "aleo/usdcx": 1000000, "aleo/eth": 0, "aleo/wbtc": 10000, + "aleo/usdt": 0, "aleo/sol": 0, "aleo/usad": 0} + assert "arc20_usdt.aleo" in fake_aleo.fetched and "usad_stablecoin.aleo" in fake_aleo.fetched + unsigned = Bridge(FakeAleo(mappings=default_mappings(), default_account=False)).status().chains[0] + assert (unsigned.address, unsigned.can_sign) == (None, False) and set(unsigned.balances.values()) == {0} + + +def test_from_env_builds_aleo_only(monkeypatch, fake_aleo): + captured = {} + + def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None): + captured.update(endpoint=endpoint, network=network, private_key=private_key, api_key=api_key, consumer_id=consumer_id) + return FakeAleo(mappings=default_mappings(), network_name=network) + + monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) + for var in ("BRIDGE_PRIVATE_KEY", "ALEO_ENDPOINT", "ALEO_NETWORK", "ALEO_API_KEY", "ALEO_CONSUMER_ID", "EVM_PRIVATE_KEY", + "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", + "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + with pytest.raises(ConfigurationError, match="BRIDGE_PRIVATE_KEY"): + Bridge.from_env() + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") + bridge = Bridge.from_env() + assert captured == {"endpoint": "https://edge.provable.com/api", "network": "mainnet", "private_key": "APrivateKey1zkpTest", + "api_key": None, "consumer_id": None} + assert bridge.environment == "mainnet" and bridge.ethereum is None and bridge.solana is None and bridge.checkpoints is None + monkeypatch.setenv("ALEO_NETWORK", "testnet") + monkeypatch.setenv("ALEO_ENDPOINT", "https://api.provable.com/v2") + monkeypatch.setenv("ALEO_API_KEY", "k") + monkeypatch.setenv("ALEO_CONSUMER_ID", "c") + assert Bridge.from_env().environment == "testnet" + assert (captured["endpoint"], captured["api_key"], captured["consumer_id"]) == ("https://api.provable.com/v2", "k", "c") + marker = object() + assert Bridge.from_env(ethereum=None, solana=None).ethereum is None + assert Bridge.from_env(checkpoints=marker).checkpoints is marker + with pytest.raises(TypeError, match="unexpected"): + Bridge.from_env(w3=marker) + + +def test_from_env_side_chain_variables(monkeypatch, tmp_path): + monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *a, **k: FakeAleo(mappings=default_mappings())) + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", + "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("EVM_PRIVATE_KEY", "0x" + "11" * 32) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Bridge.from_env() + monkeypatch.setenv("ETHEREUM_RPC_URL", "https://eth.example") + bridge = Bridge.from_env() # plan 2: real Ethereum connection now constructed + assert isinstance(bridge.ethereum, Ethereum) + assert bridge.ethereum.address == EthAccount.from_key("0x" + "11" * 32).address + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.delenv("ETHEREUM_RPC_URL") + pytest.importorskip("solders") + from solders.keypair import Keypair + + from aleo_bridge._base58 import b58encode + from aleo_bridge.sol import Solana + + solana_key = Keypair() + monkeypatch.setenv("SOLANA_PRIVATE_KEY", b58encode(bytes(solana_key))) + bridge = Bridge.from_env() # plan 3/4 (Task 4): real Solana connection now constructed + assert isinstance(bridge.solana, Solana) + assert bridge.solana.can_sign and bridge.solana.address == str(solana_key.pubkey()) + monkeypatch.delenv("SOLANA_PRIVATE_KEY") + monkeypatch.setenv("BRIDGE_CHECKPOINT_DIR", str(tmp_path / "cp")) + bridge = Bridge.from_env() # plan 4: FileCheckpointStore now wired + from aleo_bridge.checkpoint import FileCheckpointStore + assert isinstance(bridge.checkpoints, FileCheckpointStore) + assert bridge.checkpoints.directory == tmp_path / "cp" and bridge.checkpoints.directory.is_dir() + + +def test_build_aleo_is_local_only(): + from aleo import testnet as net + from aleo_bridge.client import build_aleo + key = net.PrivateKey.random() + aleo = build_aleo("https://edge.provable.com/api", "testnet", str(key)) + assert aleo.network_name == "testnet" and str(aleo.default_account.address) == str(key.address) + + +def test_from_profile_uses_profile_and_wires_no_side_chains(tmp_path, monkeypatch): + captured = {} + + def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None): + captured.update(endpoint=endpoint, network=network, private_key=private_key) + return FakeAleo(mappings=default_mappings(), network_name=network) + + monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) + for var in ("BRIDGE_PRIVATE_KEY", "BRIDGE_PRIVATE_KEY_FILE", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", + "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", + "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "home")) + bridge = Bridge.from_profile(network="testnet", endpoint="https://api.provable.com/v2") + assert bridge.profile is not None and bridge.profile.home == tmp_path / "home" + assert captured == {"endpoint": "https://api.provable.com/v2", "network": "testnet", "private_key": bridge.profile.private_key} + assert bridge.environment == "testnet" and bridge.ethereum is None and bridge.solana is None + from aleo_bridge.checkpoint import FileCheckpointStore + assert isinstance(bridge.checkpoints, FileCheckpointStore) # plan 4 binds FileCheckpointStore(profile.checkpoint_dir) + assert bridge.checkpoints.directory == bridge.profile.checkpoint_dir + assert bridge.profile.checkpoint_dir.is_dir() + marker = Ethereum(w3=fake_web3()) + assert Bridge.from_profile(ethereum=marker).ethereum is marker + + +def test_cli_lists_routes_and_assets(capsys): + assert cli.main(["routes"]) == 0 + routes = json.loads(capsys.readouterr().out) + assert len(routes) == 22 and routes[0] == "xreserve:ethereum/usdc->aleo/usdcx" + assert cli.main(["assets"]) == 0 + assert len(json.loads(capsys.readouterr().out)) == 19 + assert cli.main(["bogus"]) == 2 + + +def test_cli_prints_agent_guide_by_default(capsys): + import aleo_bridge + + assert cli.main([]) == 0 + out = capsys.readouterr().out + assert out == aleo_bridge.agent_guide() + assert "# aleo-bridge — agent guide" in out diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py new file mode 100644 index 0000000..03882e1 --- /dev/null +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -0,0 +1,179 @@ +"""The Bridge methods are one-liners; test that each forwards every argument to +lifecycle.* by calling the unbound methods with the FakeBridge as ``self``. + +Deviation from the task-9 brief (recorded in task-9-report.md): the brief's Step 3 also has +``__init__.py`` import ``bridge_tools``/``dispatch_tool`` from a new ``.agent`` module and add +``agent_guide()`` (reading a packaged ``AGENTS.md``), and has ``__main__.py`` print that guide. +The task-9 controller notes (ruling 5) explicitly forbid creating ``agent.py``/``AGENTS.md`` in +that task — those are Task 10/12's files. Task 10 landed ``agent.py`` and those three exports, +so the export pin below now covers them; ``AGENTS.md`` is still Task 12's, and ``__main__.py`` +is left untouched. +""" +import inspect +from pathlib import Path + +import aleo_bridge +from aleo_bridge import agent, lifecycle +from aleo_bridge.checkpoint import FileCheckpointStore, create_checkpoint +from aleo_bridge.client import Bridge +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +# The full pre-existing __all__ (plans 1-3, before this task's additive edit) — pinned so this +# task's edit can only ever ADD names, never drop one (controller ruling 1). +PRE_EXISTING_EXPORTS = [ + "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", + "CheckpointInvalidError", "ConfigurationError", "DeliveryUnknownError", "InsufficientBalanceError", + "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", + "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", + "UnsupportedRouteError", + "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", + "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", + "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", + "EthModule", "Ethereum", "EvmCall", + "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", +] + +# This task's own additions (lifecycle module + the pure prepare() convenience import), then the +# agent surface Task 9 deferred to Task 10 (bridge_tools/dispatch_tool + the packaged guide). +NEW_EXPORTS = ["lifecycle", "prepare", "agent_guide", "bridge_tools", "dispatch_tool"] + + +def _spy(monkeypatch, name): + seen = {} + + def fake(bridge, *args, **kwargs): + seen["args"], seen["kwargs"], seen["bridge"] = args, kwargs, bridge + return "result" + monkeypatch.setattr(lifecycle, name, fake) + return seen + + +def test_quote_forwards(monkeypatch): + seen = _spy(monkeypatch, "quote") + b = FakeBridge() + assert Bridge.quote(b, "ethereum/usdc", "aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT, + mint_mode="private", secret_nonce="7scalar", sender=EVM_ADDRESS, protocol="xreserve") == "result" + assert seen["bridge"] is b and seen["args"] == () + assert seen["kwargs"] == dict(source="ethereum/usdc", destination="aleo/usdcx", amount="2", amount_atomic=None, + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, protocol="xreserve", + mint_mode="private", secret_nonce="7scalar") + + +def test_execute_wait_get_status_recover_resume_complete_forward(monkeypatch): + b = FakeBridge() + seen = _spy(monkeypatch, "execute") + cb = lambda cp: None + 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, + secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) + seen = _spy(monkeypatch, "wait") + on_err = lambda exc: None + Bridge.wait(b, "PROGRESS", until=[Status.DELIVERY_PENDING], poll_seconds=1, timeout_seconds=2, on_update=cb, + on_error=on_err, max_consecutive_errors=9) + assert seen["args"] == ("PROGRESS",) and seen["kwargs"] == dict(until=[Status.DELIVERY_PENDING], poll_seconds=1, + timeout_seconds=2, on_update=cb, on_error=on_err, + max_consecutive_errors=9) + seen = _spy(monkeypatch, "get_status") + Bridge.get_status(b, "PLAN", "RECEIPT") + assert seen["args"] == ("PLAN", "RECEIPT") + seen = _spy(monkeypatch, "recover") + Bridge.recover(b, {"version": 1}) + assert seen["args"] == ({"version": 1},) + seen = _spy(monkeypatch, "resume") + Bridge.resume(b, "PROGRESS", on_checkpoint=cb, secret_nonce="1scalar", poll_seconds=1.0, timeout_seconds=9.0) + assert seen["kwargs"] == dict(on_checkpoint=cb, secret_nonce="1scalar", poll_seconds=1.0, timeout_seconds=9.0, + proving="delegate") + seen = _spy(monkeypatch, "complete") + Bridge.complete(b, "PROGRESS", secret_nonce="7scalar", on_checkpoint=cb) + assert seen["kwargs"] == dict(secret_nonce="7scalar", on_checkpoint=cb, proving="delegate") + + +class _Boom: + """Any attribute access returns a callable that raises — stands in for ``eth``/``sol`` so a + test can assert ``pending()`` never touches the network, not merely that it happened to answer + correctly this time.""" + + def __getattr__(self, name): + def raiser(*args, **kwargs): + raise AssertionError(f"Bridge.pending() must not touch the network (called .{name})") + return raiser + + +def test_pending_recovers_every_stored_checkpoint_offline(tmp_path): + # Fix round 1 (F1): pending() must not touch the network — one unreachable chain must never + # hide every other in-flight transfer. An EVM and a Solana checkpoint in the same store, with + # both side-chain modules wired to raise on ANY call, still both come back. + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + b._eth, b._sol = _Boom(), _Boom() + + evm_plan = lifecycle.prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS) + store.save(create_checkpoint(evm_plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_CONFIRMING, source_tx_id="0x" + "11" * 32, + protocol_state={"routeId": evm_plan.route_id}), b.registry)) + sol_plan = lifecycle.prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + store.save(create_checkpoint(sol_plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": sol_plan.route_id}), + b.registry)) + + out = Bridge.pending(b) + assert len(out) == 2 and all(p.next == "wait" for p in out) and b.calls == [] + assert {p.receipt.source_tx_id for p in out} == {"0x" + "11" * 32, "sig"} + assert Bridge.pending(FakeBridge(ethereum=False)) == [] + + +def test_pending_folds_a_malformed_checkpoint_into_a_failed_entry(tmp_path): + # F1: a checkpoint that cannot be interpreted (well-formed JSON/route, but nothing to build a + # receipt from — e.g. a truncated file that lost its "source" block) must not hide the + # healthy checkpoints alongside it in the same store. + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan = lifecycle.prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS) + store.save(create_checkpoint(plan, Receipt(id="at1good", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1good", protocol_state={"routeId": plan.route_id}), + b.registry)) + store.save(create_checkpoint(plan, Receipt(id="at1ghost", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id}), b.registry)) # no source tx at all + + out = Bridge.pending(b) + assert len(out) == 2 + good, bad = out + assert good.next == "wait" and good.receipt.source_tx_id == "at1good" + assert bad.next == "failed" and bad.error is not None and "no submitted source transaction" in bad.error + + +def test_bridge_verb_signatures_are_a_superset_of_the_lifecycle_verb_they_forward_to(): + # F2 guard: a future edit that drops a kwarg from a Bridge verb (without dropping it from the + # matching lifecycle verb too) fails loudly here instead of silently losing a caller option. + for name in ("quote", "execute", "get_status", "wait", "recover", "resume", "complete"): + bridge_params = set(inspect.signature(getattr(Bridge, name)).parameters) + lifecycle_params = set(inspect.signature(getattr(lifecycle, name)).parameters) - {"bridge"} + assert lifecycle_params <= bridge_params, name + + +def test_public_exports_pin_pre_existing_set_and_add_lifecycle_names(): + for name in PRE_EXISTING_EXPORTS + NEW_EXPORTS: + 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.1.0" + assert aleo_bridge.lifecycle is lifecycle + assert aleo_bridge.prepare is lifecycle.prepare + assert aleo_bridge.bridge_tools is agent.bridge_tools + assert aleo_bridge.dispatch_tool is agent.dispatch_tool + # AGENTS.md is Task 12's generated file: until it ships, the guide is a pointer, never an error + guide = aleo_bridge.agent_guide() + assert isinstance(guide, str) and guide + if not (Path(aleo_bridge.__file__).with_name("AGENTS.md")).exists(): + assert "codegen/gen_context.py" in guide diff --git a/bridge-sdk/tests/test_encoding.py b/bridge-sdk/tests/test_encoding.py new file mode 100644 index 0000000..0940f8a --- /dev/null +++ b/bridge-sdk/tests/test_encoding.py @@ -0,0 +1,214 @@ +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.errors import AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError + +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +RECIPIENT_BYTES32 = "b102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +EVM1 = "0x0000000000000000000000000000000000000001" +WRAPPER = "shielded_usdcx_wrapper.aleo" +MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" + + +# ── bech32m (veil test/utils/xreserve.test.ts) ──────────────────────────────── + +def test_aleo_address_round_trip_and_checksum(): + assert enc.aleo_address_to_bytes32(RECIPIENT).hex() == RECIPIENT_BYTES32 + assert enc.bytes32_to_aleo_address(bytes.fromhex(RECIPIENT_BYTES32)) == RECIPIENT + with pytest.raises(InvalidRecipientError, match="Invalid Aleo recipient"): + enc.aleo_address_to_bytes32(RECIPIENT[:-1] + "q") + with pytest.raises(InvalidRecipientError): + enc.aleo_address_to_bytes32("aleo1short") + with pytest.raises(InvalidRecipientError): + enc.aleo_address_to_bytes32("aleo1" + "b" * 58) # 'b' is not in the alphabet + with pytest.raises(InvalidRecipientError, match="32-byte Aleo recipient"): + enc.bytes32_to_aleo_address(b"\x01") + + +def test_bech32_decoder_agrees_with_the_bindings(): + # Address.from_string(...).to_bits_le() is a 253-bit field-element encoding (no fixed byte + # width) and Plaintext.from_string(...).to_bits_le() prefixes a literal-type discriminant + # (279 bits total here) — neither packs cleanly into 32 bytes. to_bytes_le() is the API that + # actually returns the 32-byte payload, so that's what the bech32m payload is checked against. + from aleo import mainnet as net + packed = bytes(net.Address.from_string(RECIPIENT).to_bytes_le()) + assert len(packed) == 32 + assert enc.aleo_address_to_bytes32(RECIPIENT) == packed + + +# ── EVM addresses ───────────────────────────────────────────────────────────── + +def test_evm_address_to_bytes32_left_pads_and_checks_eip55(): + assert enc.evm_address_to_bytes32(EVM1).hex() == "00" * 31 + "01" + assert enc.evm_address_to_bytes32("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238").hex() == \ + "0000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c7238" + assert enc.is_evm_address("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238") # all-lowercase is fine + assert enc.to_checksum_address("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238") == \ + "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + with pytest.raises(InvalidRecipientError, match="Invalid Ethereum recipient"): + enc.evm_address_to_bytes32("0x1234") + with pytest.raises(InvalidRecipientError): # mixed case with a wrong checksum + enc.evm_address_to_bytes32("0x1C7D4B196Cb0C7B01d743Fbc6116a902379C7238") + + +# ── Hyperlane limbs (veil test/utils/hyperlane.test.ts) ─────────────────────── + +def test_evm_limbs(): + assert enc.evm_address_to_hyperlane_recipient("0x1e196d0a7d8189054c4db744ab3340c3f1c68b19") == ( + 13858749752514421660238621190289096704, 33956464229475118999063216025592496509) + assert enc.evm_address_to_hyperlane_recipient(EVM1) == (0, 1329227995784915872903807060280344576) + for bad in ("0x1234", "0x" + "11" * 32): + with pytest.raises(InvalidRecipientError, match="Invalid Ethereum Hyperlane recipient"): + enc.evm_address_to_hyperlane_recipient(bad) + + +def test_solana_limbs(): + assert enc.solana_address_to_hyperlane_recipient("8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7") == ( + 127878782877948140186055645953777992816, 163261512394675613100746600600636171918) + assert enc.solana_address_to_hyperlane_recipient("11111111111111111111111111111111") == (0, 0) + for bad in ("not-base58!", "1111"): + with pytest.raises(InvalidRecipientError, match="Invalid Solana Hyperlane recipient"): + enc.solana_address_to_hyperlane_recipient(bad) + + +def test_limbs_and_literals(): + assert enc.bytes32_to_u128_limbs(bytes(31) + b"\x01") == (0, 1 << 120) + with pytest.raises(InvalidRecipientError): + enc.bytes32_to_u128_limbs(bytes(31)) + assert enc.u128_pair_literal((0, 1329227995784915872903807060280344576)) == \ + "[0u128, 1329227995784915872903807060280344576u128]" + assert enc.u8_array_literal(bytes.fromhex("00ff")) == "[0u8,255u8]" + assert enc.u8_array_literal(bytes(31) + b"\x01") == "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" + assert enc.hex_to_bytes("0x00ff", 2) == b"\x00\xff" + assert enc.hex_to_bytes(b"\x00\xff") == b"\x00\xff" + with pytest.raises(InvalidRecipientError, match="32 bytes"): + enc.hex_to_bytes("0x00ff", 32) + assert enc.to_hex(b"\x00\xff") == "0x00ff" + + +def test_hyperlane_delivery_key_vector(): + # brief §3.7 vector + key = enc.hyperlane_delivery_key(enc.hex_to_bytes(MESSAGE_ID, 32)) + assert key == "{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }" + with pytest.raises(InvalidRecipientError): + enc.hyperlane_delivery_key(b"\x00") + + +# ── xReserve wire format (veil test/utils/xreserve.test.ts) ─────────────────── + +def _payload(hook: bytes) -> tuple[bytes, bytes]: + nonce = enc.xreserve_deposit_nonce(0, bytes.fromhex("12" * 32), 4) + payload = enc.xreserve_deposit_payload( + amount=1_000_000, remote_domain=10002, + remote_token=bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401"), + remote_recipient=enc.aleo_address_to_bytes32(RECIPIENT), + local_token="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", depositor=EVM1, + max_fee=100_000, nonce=nonce, hook_data=hook) + return nonce, payload + + +def test_deposit_nonce_payload_and_hash_layout(): + nonce, payload = _payload(enc.xreserve_hook_data("public", RECIPIENT, "testnet")) + assert len(nonce) == 32 and len(payload) == 305 + assert payload[0:8].hex() == "5a2e0acd00000001" + assert payload[8:40] == (1_000_000).to_bytes(32, "big") + assert payload[40:44] == (10002).to_bytes(4, "big") + assert payload[44:76].hex() == "b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401" + assert payload[76:108].hex() == RECIPIENT_BYTES32 + assert payload[108:140].hex() == "0000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c7238" + assert payload[140:172].hex() == "00" * 31 + "01" + assert payload[172:204] == (100_000).to_bytes(32, "big") + assert payload[204:236] == nonce + assert payload[236:240].hex() == "00000041" + assert payload[240:305] == bytes(65) + assert len(enc.xreserve_message_hash(payload)) == 32 + assert enc.xreserve_nonce_from_payload(payload) == nonce + + +def test_deposit_nonce_matches_abi_encoding_via_web3(): + web3 = pytest.importorskip("web3") + from eth_abi import encode + tx_hash = bytes.fromhex("12" * 32) + expected = bytes(web3.Web3.keccak(encode(["uint32"], [0]) + tx_hash + encode(["uint256"], [4]))) + assert enc.xreserve_deposit_nonce(0, tx_hash, 4) == expected + assert enc.xreserve_deposit_nonce(0, "0x" + "12" * 32, 4) == expected # hex accepted too + + +def test_deposit_payload_rejects_bad_widths(): + good = dict(amount=1, remote_domain=1, remote_token=bytes(32), remote_recipient=bytes(32), + local_token=EVM1, depositor=EVM1, max_fee=0, nonce=bytes(32), hook_data=bytes(65)) + with pytest.raises(InvalidRecipientError, match="remote_token must contain 32 bytes"): + enc.xreserve_deposit_payload(**{**good, "remote_token": bytes(31)}) + with pytest.raises(InvalidRecipientError, match="hook_data must contain 65 bytes"): + enc.xreserve_deposit_payload(**{**good, "hook_data": bytes(64)}) + with pytest.raises(InvalidAmountError, match="does not fit"): + enc.xreserve_deposit_payload(**{**good, "remote_domain": 1 << 32}) + with pytest.raises(InvalidRecipientError): + enc.xreserve_deposit_payload(**{**good, "depositor": "0x1234"}) + + +def test_deposit_nonce_bounds_source_domain_to_uint32(): + tx_hash = bytes.fromhex("12" * 32) + assert enc.xreserve_deposit_nonce((1 << 32) - 1, tx_hash, 0) # max uint32 is fine + with pytest.raises(InvalidAmountError, match="does not fit"): + enc.xreserve_deposit_nonce(1 << 32, tx_hash, 0) + + +def test_nonce_from_payload_rejects_bad_layout(): + with pytest.raises(AttestationError, match="invalid deposit layout"): + enc.xreserve_nonce_from_payload(bytes(305)) + with pytest.raises(AttestationError): + enc.xreserve_nonce_from_payload(bytes(304)) + + +# ── hook data (BHP256 through the real bindings) ────────────────────────────── + +def test_hook_data_public_and_record_are_pure(): + assert enc.xreserve_hook_data("public", RECIPIENT, "testnet") == bytes(65) + assert enc.xreserve_hook_data("record", RECIPIENT, "testnet") == b"\x01" + bytes(64) + + +def test_hook_data_private_commits_recipient_with_selected_scalar(): + default = enc.xreserve_hook_data("private", RECIPIENT, "testnet") + assert len(default) == 65 and default[0] == 2 and default[33:] == bytes(32) + assert default == enc.xreserve_hook_data("private", RECIPIENT, "testnet", "0scalar") + custom = enc.xreserve_hook_data("private", RECIPIENT, "testnet", "7scalar") + assert custom[0] == 2 and custom != default + # BHP256 is curve-level, not network-level: mainnet and testnet bindings agree. + assert enc.xreserve_hook_data("private", RECIPIENT, "mainnet") == default + # Independent recomputation through the bindings. + from aleo import testnet as net + commitment = net.BHP256().commit(net.Plaintext.from_string(RECIPIENT).to_bits_le(), + net.Scalar.from_string("7scalar")) + assert custom[1:33] == bytes(commitment.to_bytes_le()) + + +def test_hook_data_private_pinned_vector(): + # Pinned from the bindings on 2026-09-03 (RECIPIENT, 0scalar). If this fails while the previous + # test passes, the bindings' BHP256 output changed — investigate, do not re-pin blindly. + assert enc.xreserve_hook_data("private", RECIPIENT, "mainnet")[1:33].hex() == \ + "dd46b467d619a9628e58a71ebf24873c777f93ae2b7ac5dee3db7282ecef8d10" + + +def test_hook_data_validation(): + with pytest.raises(ConfigurationError, match="mint mode"): + enc.xreserve_hook_data("shielded", RECIPIENT, "mainnet") + with pytest.raises(ConfigurationError, match="scalar"): + enc.xreserve_hook_data("private", RECIPIENT, "mainnet", "not-a-scalar") + with pytest.raises(ConfigurationError, match="network"): + enc.xreserve_hook_data("private", RECIPIENT, "devnet") + with pytest.raises(InvalidRecipientError): + enc.xreserve_hook_data("private", "aleo1short", "mainnet") + + +# ── program address ─────────────────────────────────────────────────────────── + +def test_program_address_is_a_valid_address_and_matches_bindings(): + from aleo import mainnet as net + addr = enc.aleo_program_address(WRAPPER, "mainnet") + assert addr == str(net.Address.from_program_id(WRAPPER)) + assert len(enc.aleo_address_to_bytes32(addr)) == 32 + # Pinned 2026-09-03 from the bindings; see note on the hook-data pin above. + assert addr == "aleo183r3zgsr57fwtgk5duzeq9kqdkpmmtfj4k5469ddvm3tcfhhls9szktw82" + with pytest.raises(ConfigurationError, match="network"): + enc.aleo_program_address(WRAPPER, "devnet") diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py new file mode 100644 index 0000000..8a8063f --- /dev/null +++ b/bridge-sdk/tests/test_eth_connection.py @@ -0,0 +1,203 @@ +import pytest +from eth_account import Account +from web3 import Web3 +from web3.middleware import SignAndSendRawMiddlewareBuilder + +from aleo_bridge.errors import ConfigurationError +from tests.fakes.fake_web3 import fake_web3 + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +TO = "0x0000000000000000000000000000000000000002" + + +def test_exactly_one_transport(): + from aleo_bridge.eth import Ethereum + + with pytest.raises(ConfigurationError, match="exactly one of rpc_url or w3"): + Ethereum() + with pytest.raises(ConfigurationError, match="exactly one of rpc_url or w3"): + Ethereum("http://localhost:8545", w3=fake_web3()) + + +def test_at_most_one_signer(): + from aleo_bridge.eth import Ethereum + + with pytest.raises(ConfigurationError, match="at most one of signer or private_key"): + Ethereum(w3=fake_web3(), signer=ACCT, private_key=KEY) + + +def test_rpc_url_builds_http_provider_lazily(): + from web3 import HTTPProvider + + from aleo_bridge.eth import Ethereum + + conn = Ethereum("http://127.0.0.1:1", private_key=KEY) + assert isinstance(conn.w3.provider, HTTPProvider) + assert conn.address == ACCT.address and conn.can_sign + + +def test_w3_plus_signer_form_and_cached_chain_id(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3(chain_id=11155111) + conn = Ethereum(w3=w3, signer=ACCT) + assert conn.w3 is w3 and conn.address == ACCT.address and conn.can_sign + assert conn.chain_id == 11155111 + assert conn.chain_id == 11155111 and w3.provider.methods.count("eth_chainId") == 1 + + +def test_w3_alone_is_read_only_without_default_account(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3()) + assert conn.address is None and not conn.can_sign + with pytest.raises(ConfigurationError, match="read-only"): + conn.send_transaction({"to": TO, "value": 1, "data": "0x"}) + with pytest.raises(ConfigurationError, match="read-only"): + conn.require_address() + + +def test_w3_default_account_uses_callers_middleware(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(ACCT), layer=0) + w3.eth.default_account = ACCT.address + conn = Ethereum(w3=w3) + assert conn.address == ACCT.address and conn.can_sign + h = conn.send_transaction({"to": TO, "value": 1, "data": "0x"}) + assert h == w3.provider.hash_at(1) + assert "eth_sendRawTransaction" in w3.provider.methods # the caller's middleware signed + assert w3.provider.sent[0]["from"] == ACCT.address and w3.provider.sent[0]["value"] == 1 + + +def test_local_account_path_signs_and_sends_raw(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 7, "data": "0x"}) + assert h == w3.provider.hash_at(1) + assert "eth_sendRawTransaction" in w3.provider.methods and "eth_sendTransaction" not in w3.provider.methods + sent = w3.provider.sent[0] + assert sent["from"] == ACCT.address and sent["to"] == Web3.to_checksum_address(TO) and sent["value"] == 7 + # eth.py's fee-filling (EIP-1559 path, since the fake's eth_getBlockByNumber carries baseFeePerGas): + # nonce <- eth_getTransactionCount(sender, "pending") + # gas <- eth_estimateGas(...) * 12 // 10 (a 20% buffer) + # maxPriorityFeePerGas <- eth_maxPriorityFeePerGas + # maxFeePerGas <- baseFeePerGas * 2 + maxPriorityFeePerGas + assert sent["nonce"] == 0 + assert sent["gas"] == 150_000 * 12 // 10 + assert sent["maxPriorityFeePerGas"] == 10**8 + assert sent["maxFeePerGas"] == 10**9 * 2 + 10**8 + assert "gasPrice" not in sent + + +def test_legacy_gas_price_path_when_no_base_fee(): + """With no ``baseFeePerGas`` on the latest block (pre-EIP-1559 chain), eth.py falls back + to a plain ``gasPrice`` from ``eth_gasPrice`` and sets no 1559 fee fields.""" + from aleo_bridge.eth import Ethereum + + w3 = fake_web3(legacy=True) + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 3, "data": "0x"}) + assert h == w3.provider.hash_at(1) + sent = w3.provider.sent[0] + assert sent["gasPrice"] == 10**9 + assert "maxFeePerGas" not in sent and "maxPriorityFeePerGas" not in sent + assert "eth_maxPriorityFeePerGas" not in w3.provider.methods + + +def test_sender_mismatch_is_refused(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3(), private_key=KEY) + with pytest.raises(ConfigurationError, match="does not match the configured account"): + conn.send_transaction({"from": TO, "to": TO, "value": 0, "data": "0x"}) + + +def test_a_lost_send_response_names_the_locally_computed_hash(): + """``eth_sendRawTransaction`` failing tells us nothing about whether the node took the bytes: + the hash is already determined by the signature, so it must reach the caller.""" + from aleo_bridge.errors import BridgeError + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + w3.provider.send_errors[1] = "connection reset by peer" + conn = Ethereum(w3=w3, private_key=KEY) + signed = ACCT.sign_transaction({"to": Web3.to_checksum_address(TO), "value": 5, "data": "0x", "chainId": 1, + "nonce": 0, "gas": 150_000 * 12 // 10, "maxPriorityFeePerGas": 10**8, + "maxFeePerGas": 10**9 * 2 + 10**8}) + with pytest.raises(BridgeError) as exc: + conn.send_transaction({"to": TO, "value": 5, "data": "0x"}) + message = str(exc.value) + assert Web3.to_hex(signed.hash) in message and "may have been broadcast" in message + assert "connection reset by peer" in message + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 # exactly one attempt + + +def test_a_node_hash_that_differs_from_the_signed_one_is_refused(): + from aleo_bridge.errors import BridgeError + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + other = "0x" + "ab" * 32 + w3.provider.echo_hashes[1] = other + conn = Ethereum(w3=w3, private_key=KEY) + with pytest.raises(BridgeError) as exc: + conn.send_transaction({"to": TO, "value": 5, "data": "0x"}) + message = str(exc.value) + assert other in message and w3.provider.hash_at(1) in message + + +def test_wait_for_receipt_returns_none_on_timeout_and_dict_on_success(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 0, "data": "0x"}) + w3.provider.pending.add(h) + assert conn.wait_for_receipt(h, timeout_seconds=0.01, poll_seconds=0.001) is None + assert conn.get_receipt(h) is None + w3.provider.pending.clear() + receipt = conn.wait_for_receipt(h, timeout_seconds=1.0, poll_seconds=0.001) + assert receipt is not None and int(receipt["status"]) == 1 and int(receipt["blockNumber"]) == 0x11 + assert Web3.to_hex(conn.get_receipt(h)["transactionHash"]) == h + + +def test_get_receipt_missing_hash_is_none(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3()) + assert conn.get_receipt("0x" + "99" * 32) is None + + +def test_from_env(): + from aleo_bridge.eth import Ethereum + + assert Ethereum.from_env({}) is None + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env({"EVM_PRIVATE_KEY": KEY}) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env({"ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + conn = Ethereum.from_env({"EVM_PRIVATE_KEY": KEY, "ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" + + +def test_from_env_aliases(monkeypatch): + """``BRIDGE_EVM_PRIVATE_KEY`` / ``BRIDGE_LIVE_ETHEREUM_RPC_URL`` stand in for the primary variables.""" + from aleo_bridge.eth import Ethereum + + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("BRIDGE_EVM_PRIVATE_KEY", KEY) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env() + monkeypatch.setenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", "http://127.0.0.1:1") + conn = Ethereum.from_env() + assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" + # the primary variable wins when both a primary and its alias are set + monkeypatch.setenv("ETHEREUM_RPC_URL", "http://127.0.0.1:2") + conn2 = Ethereum.from_env() + assert conn2.w3.provider.endpoint_uri == "http://127.0.0.1:2" diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py new file mode 100644 index 0000000..99f721f --- /dev/null +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -0,0 +1,232 @@ +import dataclasses + +import pytest +from eth_account import Account +from eth_utils import keccak +from web3 import Web3 + +from aleo_bridge.errors import (BridgeError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError, RouteUnavailableError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import DispatchReceipt, Status +from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +OTHER_ALEO = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +OTHER = "0x0000000000000000000000000000000000000009" +ALEO_BYTES32 = "0xb102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +ETH_ROUTER = "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +USDT, USDT_ROUTER = "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +MESSAGE_ID = bytes.fromhex("ab" * 32) +APPROVE = keccak(text="approve(address,uint256)")[:4].hex() +TRANSFER_REMOTE = keccak(text="transferRemote(uint32,bytes32,uint256)")[:4].hex() + + +def setup(router, *, with_dispatch_log=True, **config): + w3 = fake_web3(**config) + if with_dispatch_log: + w3.provider.receipt_logs = lambda tx: ( + [dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=tx["hash"])] if tx["to"] == Web3.to_checksum_address(router) else []) + bridge = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)) + return bridge.eth, w3 + + +def test_native_eth_dispatch_is_one_transaction_with_value(): + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 69_000_000_000_101)]}) + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert isinstance(result, DispatchReceipt) + assert len(w3.provider.sent) == 1 and w3.provider.sent[0]["value"] == 0x3EC1507D5065 + assert w3.provider.sent[0]["to"] == Web3.to_checksum_address(ETH_ROUTER) + assert w3.provider.sent[0]["data"][2:10] == TRANSFER_REMOTE + receipt = result.receipt + assert receipt.status == Status.DELIVERY_PENDING and receipt.protocol == "hyperlane" + assert result.message_id == Web3.to_hex(MESSAGE_ID) and receipt.id == result.message_id + assert receipt.source_tx_id == w3.provider.hash_at(1) == result.transaction_id and result.amount_atomic == 100 + assert receipt.protocol_state == { + "routeId": "hyperlane:ethereum/eth->aleo/eth", "approvalTxIds": [], "sourceSender": ACCT.address, + "recipientBytes32": ALEO_BYTES32, "destinationDomain": 1634493807, + "nativeValueAtomic": "69000000000101", "amountAtomic": "100", "messageId": Web3.to_hex(MESSAGE_ID), + } + + +def test_wbtc_approves_exact_token_amount_then_dispatches_with_fee_value(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + result = eth.transfer_remote("ethereum/wbtc", ALEO, amount="0.001").send(poll_seconds=0.001) + sent = w3.provider.sent + assert [t["to"] for t in sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(WBTC_ROUTER)] + assert sent[0]["data"][2:].lower() == APPROVE + WBTC_ROUTER[2:].lower().rjust(64, "0") + format(100_000, "064x") + assert sent[0]["value"] == 0 and sent[1]["value"] == 0xC350 + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and result.receipt.source_tx_id == w3.provider.hash_at(2) + + +def test_wbtc_sufficient_allowance_skips_approval(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 100_000}) + result = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 and result.receipt.protocol_state["approvalTxIds"] == [] + + +def test_usdt_resets_non_zero_allowance_first(): + eth, w3 = setup(USDT_ROUTER, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}, + allowances={(USDT, ACCT.address, USDT_ROUTER): 1}) + result = eth.transfer_remote("usdt", ALEO, amount="1").send(poll_seconds=0.001) + sent = w3.provider.sent + assert len(sent) == 3 + assert sent[0]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + "0" * 64 + assert sent[1]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + format(1_000_000, "064x") + assert sent[2]["data"][2:10] == TRANSFER_REMOTE + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1), w3.provider.hash_at(2)] + + +def test_usdt_zero_allowance_needs_no_reset(): + eth, w3 = setup(USDT_ROUTER, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}) + eth.transfer_remote("usdt", ALEO, amount="1").send(poll_seconds=0.001) + assert len(w3.provider.sent) == 2 + + +def test_approval_timeout_is_pending_and_checkpointed_before_polling(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + w3.provider.pending_nth.add(1) + seen = [] + result = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).send( + timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.id == w3.provider.hash_at(1) and result.message_id is None and len(w3.provider.sent) == 1 + assert [cp.source for cp in seen] == [{"approvalTransactionIds": [w3.provider.hash_at(1)]}] + assert seen[0].intent == {"source": {"chain": "ethereum", "asset": "wbtc"}, "destination": {"chain": "aleo", "asset": "wbtc"}, + "bridgeProtocol": "hyperlane", "amount": "0.001", "recipient": ALEO, + "sender": ACCT.address, "mintMode": "public"} + + +def test_dispatch_timeout_is_source_confirming_with_hash(): + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.pending_nth.add(1) + seen = [] + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send( + timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) + assert result.receipt.id == w3.provider.hash_at(1) and "messageId" not in result.receipt.protocol_state + assert [cp.source for cp in seen] == [{"transactionId": w3.provider.hash_at(1)}] + + +def test_dispatch_id_survives_unrelated_log_before_it(): + """A log from an unrelated event (different address/topic, e.g. an ERC-20 Transfer) preceding the + Mailbox DispatchId log in the receipt must not prevent the message id from being decoded.""" + noise_topic = "0x" + keccak(text="Transfer(address,address,uint256)").hex() + + def logs_with_noise_first(tx): + noise = event_log(WBTC, [noise_topic], "0x", log_index=1, tx_hash=tx["hash"]) + dispatch = dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=tx["hash"], log_index=2) + return [noise, dispatch] if tx["to"] == Web3.to_checksum_address(ETH_ROUTER) else [] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = logs_with_noise_first + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(MESSAGE_ID) + + +def test_first_dispatch_id_wins_when_receipt_has_two(): + """veil's ``messageIdFromReceipt`` returns the FIRST matching DispatchId event, not the last.""" + first_id, second_id = bytes.fromhex("11" * 32), bytes.fromhex("22" * 32) + + def two_dispatch_logs(tx): + return [dispatch_id_log(MAILBOX, first_id, tx_hash=tx["hash"], log_index=1), + dispatch_id_log(MAILBOX, second_id, tx_hash=tx["hash"], log_index=2)] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = two_dispatch_logs + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(first_id) + + +def test_dispatch_id_from_a_foreign_address_is_ignored(): + """``process_receipt`` decodes by topic only — a ``DispatchId`` emitted by anything other than the + route's Mailbox is another protocol's event and must read as "no message id".""" + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = lambda tx: [dispatch_id_log(WBTC, MESSAGE_ID, tx_hash=tx["hash"], log_index=1)] + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id is None and result.receipt.id == w3.provider.hash_at(1) + assert result.receipt.status == Status.DELIVERY_PENDING and "messageId" not in result.receipt.protocol_state + + +def test_mailbox_dispatch_id_wins_over_an_earlier_foreign_one(): + """First match *among the Mailbox's own* events, not first match overall.""" + foreign, mine = bytes.fromhex("11" * 32), bytes.fromhex("22" * 32) + + def logs(tx): + return [dispatch_id_log(WBTC, foreign, tx_hash=tx["hash"], log_index=1), + dispatch_id_log(MAILBOX, mine, tx_hash=tx["hash"], log_index=2)] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = logs + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(mine) + + +def test_missing_dispatch_id_log_keeps_tx_hash_as_id(): + eth, w3 = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.receipt.status == Status.DELIVERY_PENDING and result.message_id is None + assert result.receipt.id == w3.provider.hash_at(1) and "messageId" not in result.receipt.protocol_state + + +def test_build_lists_approval_then_dispatch_without_sending(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + txs = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(WBTC_ROUTER)] + assert [t["value"] for t in txs] == [0, 50_000] and w3.provider.sent == [] + + +def test_plan_driven_transfer_is_identical_to_the_asset_driven_one(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + quote = eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000) + by_asset = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).build() + by_plan = eth.transfer_remote(plan=quote.plan).build() + assert by_plan == by_asset and len(by_plan) == 2 + assert eth.quote_transfer_remote(plan=quote.plan) == quote # the quote round-trips through its own plan + assert w3.provider.sent == [] + + +def test_plan_driven_transfer_rejects_a_tampered_stale_or_foreign_plan(): + """Nothing in a caller-supplied plan is trusted, and every rejection happens before any RPC.""" + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + plan = eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000).plan + w3.provider.methods.clear() + with pytest.raises(BridgeError, match="plan does not match the requested transfer: amount"): + eth.transfer_remote(plan=dataclasses.replace(plan, amount_atomic=99_999)) + with pytest.raises(BridgeError, match="plan does not match the requested transfer: recipient"): + eth.transfer_remote(recipient=OTHER_ALEO, plan=plan) # explicit argument vs the plan's own value + with pytest.raises(RegistryVersionMismatchError): + eth.transfer_remote(plan=dataclasses.replace(plan, registry_version="0000-00-00.stale")) + with pytest.raises(RouteUnavailableError, match="not a hyperlane one"): + eth.transfer_remote(plan=dataclasses.replace(plan, route_id="xreserve:ethereum/usdc->aleo/usdcx")) + with pytest.raises(RouteUnavailableError): + eth.transfer_remote(plan=dataclasses.replace(plan, route_id="hyperlane:nowhere/nothing->aleo/eth")) + with pytest.raises(ConfigurationError, match="does not match connected account"): + eth.transfer_remote(plan=dataclasses.replace(plan, sender=OTHER)) + with pytest.raises(BridgeError, match="checksummed EVM address"): + eth.transfer_remote(plan=dataclasses.replace(plan, sender=None)) + with pytest.raises(ValueError, match="not both"): + eth.transfer_remote("wbtc", plan=plan) + with pytest.raises(ValueError, match="not both"): + eth.quote_transfer_remote(plan=plan, sender=ACCT.address) + assert w3.provider.methods == [] and w3.provider.sent == [] + + +def test_read_only_connection_cannot_transfer(): + w3 = fake_web3(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + eth = make_bridge(ethereum=Ethereum(w3=w3)).eth + with pytest.raises(ConfigurationError, match="read-only"): + eth.transfer_remote("eth", ALEO, amount_atomic=100) + + +def test_transfer_remote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """Same guard as the quote path: a missing recipient is an InvalidRecipientError, not a TypeError.""" + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.transfer_remote("eth", amount_atomic=100) + assert w3.provider.methods == [] and w3.provider.sent == [] diff --git a/bridge-sdk/tests/test_eth_hyperlane_quote.py b/bridge-sdk/tests/test_eth_hyperlane_quote.py new file mode 100644 index 0000000..2bec8b0 --- /dev/null +++ b/bridge-sdk/tests/test_eth_hyperlane_quote.py @@ -0,0 +1,155 @@ +import dataclasses + +import pytest +from eth_account import Account + +from aleo_bridge.encoding import aleo_address_to_bytes32, bytes32_to_aleo_address +from aleo_bridge.errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, + InvalidAmountError, InvalidRecipientError, RouteUnavailableError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import EvmHyperlaneQuote +from tests.fakes.fake_web3 import ZERO_ADDRESS, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO_BYTES32 = "b102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +VEIL_RECIPIENT_BYTES32 = "20e3629764d5338f74bee96675801b1fb29d1fc68b177668f9175708bef84311" +ETH_ROUTER = "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +USDT, USDT_ROUTER = "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31" + + +def eth_module(*, signed=True, **config): + w3 = fake_web3(**config) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(ethereum=conn).eth, w3 + + +def test_native_eth_quote_splits_fee_from_value(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 69_000_000_000_101)]}) + q = eth.quote_transfer_remote("ethereum/eth", ALEO, amount_atomic=100) + assert isinstance(q, EvmHyperlaneQuote) and q.kind == "evm-hyperlane" + assert q.plan.route_id == "hyperlane:ethereum/eth->aleo/eth" and q.plan.amount == "0.0000000000000001" + assert q.plan.sender == ACCT.address and q.amount_out == "0.0000000000000001" + assert q.native_value_atomic == 69_000_000_000_101 and q.native_fee_atomic == 69_000_000_000_001 + assert q.approval_required is None + assert q.recipient_bytes32.hex() == ALEO_BYTES32 + assert len(q.fees) == 1 and q.fees[0].kind == "network" and q.fees[0].chain_id == "ethereum" + assert q.fees[0].asset_id == "ethereum/eth" and q.fees[0].amount == "0.000069000000000001" and q.fees[0].estimated + + +def test_recipient_bytes32_matches_veil_vector(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + recipient = bytes32_to_aleo_address(bytes.fromhex(VEIL_RECIPIENT_BYTES32)) + q = eth.quote_transfer_remote("eth", recipient, amount_atomic=1) + assert q.recipient_bytes32 == bytes.fromhex(VEIL_RECIPIENT_BYTES32) + assert aleo_address_to_bytes32(recipient) == q.recipient_bytes32 + assert q.plan.recipient == recipient + + +def test_collateral_wbtc_quote_reads_allowance(): + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 0}) + q = eth.quote_transfer_remote("ethereum/wbtc", ALEO, amount="0.001") + assert q.plan.amount_atomic == 100_000 and q.native_value_atomic == 50_000 and q.native_fee_atomic == 50_000 + assert q.approval_required is True + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 100_000}) + assert eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000).approval_required is False + + +def test_read_only_connection_quotes_with_explicit_or_no_sender(): + eth, _ = eth_module(signed=False, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}, + allowances={(USDT, ACCT.address, USDT_ROUTER): 1}) + q = eth.quote_transfer_remote("usdt", ALEO, amount="1") + assert q.approval_required is None and q.plan.sender is None + q = eth.quote_transfer_remote("usdt", ALEO, amount="1", sender=ACCT.address) + assert q.approval_required is True and q.plan.sender == ACCT.address + + +def test_quote_must_cover_amount(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 50)]}) + with pytest.raises(BridgeError, match="Native Hyperlane quote does not cover"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=100) + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 99_999)]}) + with pytest.raises(BridgeError, match="Collateral Hyperlane quote does not cover"): + eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000) + + +def test_wrong_chain_is_refused_before_any_contract_read(): + eth, w3 = eth_module(chain_id=11155111, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + with pytest.raises(ChainMismatchError, match="expected 1"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1) + assert "eth_call" not in w3.provider.methods + + +def test_unavailable_unknown_and_explicit_routes(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(RouteUnavailableError): + eth.quote_transfer_remote("ethereum/usad", ALEO, amount_atomic=1) + with pytest.raises(BridgeError): + eth.quote_transfer_remote("ethereum/doge", ALEO, amount_atomic=1) + with pytest.raises(BridgeError, match="not a Hyperlane route"): + eth.quote_transfer_remote("ethereum/usdc", ALEO, amount_atomic=1) + route = DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth") + assert eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route).plan.route_id == route.id + + +def _corrupted_eth_route(**overrides): + route = DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth") + return dataclasses.replace(route, metadata={**route.metadata, **overrides}) + + +def test_corrupted_router_address_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(routerAddress="not-an-address") + with pytest.raises(ConfigurationError, match="routerAddress"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bad_router_type_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(routerType="burn") + with pytest.raises(ConfigurationError, match="routerType"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_out_of_range_destination_domain_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(destinationDomain=2**32) + with pytest.raises(ConfigurationError, match="destinationDomain"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bad_registry_commit_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(registryCommit="not-hex") + with pytest.raises(ConfigurationError, match="registryCommit"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_amount_and_recipient_validation(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidAmountError): + eth.quote_transfer_remote("eth", ALEO, amount="1", amount_atomic=1) + with pytest.raises(InvalidAmountError): + eth.quote_transfer_remote("eth", ALEO) + with pytest.raises(InvalidRecipientError): + eth.quote_transfer_remote("eth", "aleo1notanaddress", amount_atomic=1) + with pytest.raises(InvalidRecipientError): + eth.quote_transfer_remote("eth", "0x0000000000000000000000000000000000000001", amount_atomic=1) + + +def test_quote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """``recipient`` is only optional when ``plan=`` supplies it; otherwise it must be named, + not surface as the opaque TypeError ``_recipient_bytes32(None)`` used to raise.""" + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.quote_transfer_remote("eth", amount_atomic=100) + assert w3.provider.methods == [] # refused before any contract read diff --git a/bridge-sdk/tests/test_eth_recover.py b/bridge-sdk/tests/test_eth_recover.py new file mode 100644 index 0000000..75ab2b3 --- /dev/null +++ b/bridge-sdk/tests/test_eth_recover.py @@ -0,0 +1,318 @@ +import dataclasses + +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.checkpoint import create_checkpoint +from aleo_bridge.errors import (BridgeError, CheckpointInvalidError, ConfigurationError, + RegistryVersionMismatchError) +from aleo_bridge.eth import LOG_SCAN_CHUNK_BLOCKS, EthModule, Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_web3 import deposited_log, dispatch_id_log, fake_web3, make_bridge, sent_transfer_remote_log + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +OTHER = "0x0000000000000000000000000000000000000009" +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO32 = encoding.aleo_address_to_bytes32(ALEO) +WBTC_ROUTER = "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +SEPOLIA_USDC, SEPOLIA_XRESERVE = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "0x008888878f94C0d87defdf0B07f46B93C1934442" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +APPROVAL, DISPATCH = "0x" + "11" * 32, "0x" + "22" * 32 +RECOVERED, RECOVERED_2, EARLIER = "0x" + "44" * 32, "0x" + "55" * 32, "0x" + "66" * 32 +RECOVERED_MESSAGE_ID = bytes.fromhex("cd" * 32) +WBTC_ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") +WBTC_PLAN = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) + + +def hyperlane_checkpoint(plan=WBTC_PLAN, *, tx_id=None): + state = {"routeId": WBTC_ROUTE.id, "approvalTxIds": [APPROVAL], "sourceSender": plan.sender, + "recipientBytes32": "0x" + ALEO32.hex(), "destinationDomain": 1634493807, "nativeValueAtomic": "50000", "amountAtomic": "100000"} + receipt = (Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, protocol_state=state) + if tx_id else Receipt(id=APPROVAL, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state)) + return create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + + +def xreserve_checkpoint(plan, hook: bytes, *, tx_id=None): + state = {"routeId": USDC_ROUTE.id, "approvalTxIds": [APPROVAL], "sourceSender": ACCT.address, "mintMode": plan.mint_mode, + "intendedRecipient": ALEO, "xReserveContract": SEPOLIA_XRESERVE, "tokenAddress": SEPOLIA_USDC, "sourceChainId": 11155111, + "remoteDomain": 10002, "remoteRecipientBytes32": "0x" + ALEO32.hex(), "hookData": "0x" + hook.hex(), + "amountAtomic": "2000000", "maxFeeAtomic": "100000"} + receipt = (Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, protocol_state=state) + if tx_id else Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state)) + return create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + + +def mainnet_read_only(): + w3 = fake_web3() + return make_bridge(ethereum=Ethereum(w3=w3)).eth, w3 + + +def dispatch_history(w3, tx_hash, *, sender=ACCT.address, block_number=0x65, amount=100_000): + w3.provider.history_logs.append(sent_transfer_remote_log(WBTC_ROUTER, destination=1634493807, recipient32=ALEO32, + amount=amount, tx_hash=tx_hash, block_number=block_number)) + w3.provider.add_transaction(tx_hash, sender=sender, to=WBTC_ROUTER, block_number=block_number) + w3.provider.add_receipt(tx_hash, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=tx_hash)], + sender=sender, to=WBTC_ROUTER, block_number=block_number) + + +def test_checkpoint_must_match_plan_and_registry(): + eth, _ = mainnet_read_only() + cp = hyperlane_checkpoint() + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, route={**cp.route, "id": "hyperlane:ethereum/eth->aleo/eth"})) + with pytest.raises(RegistryVersionMismatchError): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, route={**cp.route, "registryVersion": "0000-00-00.stale"})) + with pytest.raises(CheckpointInvalidError, match="no submitted transaction"): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, source=None)) + + +def test_hyperlane_rejects_checkpoint_with_destination_leg(): + eth, _ = mainnet_read_only() + cp = hyperlane_checkpoint() + bad = dataclasses.replace(cp, destination={"transactionId": "0x" + "77" * 32}) + with pytest.raises(CheckpointInvalidError, match="destination"): + eth.recover_source(WBTC_PLAN, bad) + + +def test_hyperlane_unmined_approval_stays_approval_pending(): + eth, w3 = mainnet_read_only() + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_APPROVAL_PENDING and receipt.id == APPROVAL and receipt.source_tx_id is None + assert receipt.protocol_state["approvalTxIds"] == [APPROVAL] and receipt.protocol_state["sourceSender"] == ACCT.address + assert "eth_getLogs" not in w3.provider.methods + + +def test_hyperlane_confirmed_approval_without_dispatch_stops_at_submission_boundary(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + +def test_hyperlane_scan_finds_the_dispatch_after_the_approval_block(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, EARLIER, block_number=0x10) # before the approval: must be ignored by fromBlock + dispatch_history(w3, RECOVERED, block_number=0x66) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.DELIVERY_PENDING and receipt.source_tx_id == RECOVERED + assert receipt.id == Web3.to_hex(RECOVERED_MESSAGE_ID) == receipt.protocol_state["messageId"] + assert receipt.protocol_state["approvalTxIds"] == [APPROVAL] + + +def test_hyperlane_scan_ignores_other_senders_and_amounts(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, RECOVERED, sender=OTHER) + dispatch_history(w3, RECOVERED_2, amount=99_999) + assert eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()).status == Status.SOURCE_SUBMISSION_PENDING + + +def test_hyperlane_scan_skips_a_candidate_sent_to_another_contract(): + """A matching ``SentTransferRemote`` log whose transaction went somewhere other than the router + belongs to another call path (a batcher, a router of a different route) — never adopt it.""" + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + w3.provider.history_logs.append(sent_transfer_remote_log(WBTC_ROUTER, destination=1634493807, recipient32=ALEO32, + amount=100_000, tx_hash=RECOVERED, block_number=0x66)) + w3.provider.add_transaction(RECOVERED, sender=ACCT.address, to=OTHER, block_number=0x66) + w3.provider.add_receipt(RECOVERED, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=RECOVERED)], + sender=ACCT.address, to=OTHER, block_number=0x66) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.source_tx_id is None + + +def test_recovery_log_scan_is_bounded_and_chunked(): + """Every eth_getLogs carries an explicit fromBlock/toBlock; the chunks tile the range exactly + once and stop at the head read at the start of the scan.""" + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 # the approval is 25 blocks behind the head + assert eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()).status == Status.SOURCE_SUBMISSION_PENDING + assert [(f["fromBlock"], f["toBlock"]) for f in w3.provider.log_filters] == [ + (hex(101), hex(110)), (hex(111), hex(120)), (hex(121), hex(126))] + assert all(f["address"] == [Web3.to_checksum_address(WBTC_ROUTER)] for f in w3.provider.log_filters) + + +def test_a_dispatch_in_the_last_chunk_is_still_found(): + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + dispatch_history(w3, RECOVERED, block_number=126) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.DELIVERY_PENDING and receipt.source_tx_id == RECOVERED + assert len(w3.provider.log_filters) == 3 + + +def test_a_failing_log_chunk_names_the_span_it_could_not_read(): + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 + w3.provider.log_scan_errors[2] = "query returned more than 10000 results" + with pytest.raises(BridgeError, match="blocks 111-120 of 101-126") as exc: + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert "10000 results" in str(exc.value) and "smaller" in str(exc.value) + assert len(w3.provider.log_filters) == 2 # stopped at the failing chunk + + +def test_xreserve_scan_is_chunked_too(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + eth.log_scan_chunk_blocks = 10 + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 + assert eth.recover_source(plan, cp).status == Status.SOURCE_SUBMISSION_PENDING + assert [(f["fromBlock"], f["toBlock"]) for f in w3.provider.log_filters] == [ + (hex(101), hex(110)), (hex(111), hex(120)), (hex(121), hex(126))] + assert all(f["address"] == [Web3.to_checksum_address(SEPOLIA_XRESERVE)] for f in w3.provider.log_filters) + + +def test_hyperlane_multiple_matches_refuse_to_choose(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, RECOVERED) + dispatch_history(w3, RECOVERED_2) + with pytest.raises(BridgeError, match="Multiple matching Hyperlane dispatches"): + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + + +def test_hyperlane_required_scan_needs_sender_and_confirmed_approval(): + eth, w3 = mainnet_read_only() + with pytest.raises(BridgeError, match="no confirmed approval block"): + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint(), required=True) + plan_no_sender = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=None) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + with pytest.raises(BridgeError, match="without the source account"): + eth.recover_source(plan_no_sender, hyperlane_checkpoint(plan_no_sender), required=True) + assert eth.recover_source(plan_no_sender, hyperlane_checkpoint(plan_no_sender)).status == Status.SOURCE_SUBMISSION_PENDING + + +def test_hyperlane_required_scan_with_no_matching_dispatch_is_not_fatal(): + """A completed scan (known sender, confirmed approval) that matches nothing is a valid answer, + not an inability to scan: required=True still returns SOURCE_SUBMISSION_PENDING, never sends.""" + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint(), required=True) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + +def test_hyperlane_saved_dispatch_is_observed_not_resent(): + eth, w3 = mainnet_read_only() + cp = hyperlane_checkpoint(tx_id=DISPATCH) + pending = eth.recover_source(WBTC_PLAN, cp) + assert pending.status == Status.SOURCE_CONFIRMING and pending.source_tx_id == DISPATCH + w3.provider.add_receipt(DISPATCH, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=DISPATCH)], sender=ACCT.address, to=WBTC_ROUTER) + done = eth.recover_source(WBTC_PLAN, cp) + assert done.status == Status.DELIVERY_PENDING and done.id == Web3.to_hex(RECOVERED_MESSAGE_ID) and w3.provider.sent == [] + + +def test_xreserve_scan_recovers_a_confirmed_deposit_from_an_approval_only_checkpoint(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + hook = bytes(65) + cp = xreserve_checkpoint(plan, hook) + assert cp.source["hookData"] == "0x" + hook.hex() + assert eth.recover_source(plan, cp).status == Status.SOURCE_APPROVAL_PENDING + w3.provider.add_receipt(APPROVAL, block_number=0x65) + assert eth.recover_source(plan, cp).status == Status.SOURCE_SUBMISSION_PENDING + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED, log_index=3) + w3.provider.history_logs.append(log) + w3.provider.add_receipt(RECOVERED, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + other = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=OTHER, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED_2, log_index=1) + w3.provider.history_logs.append(other) + w3.provider.add_receipt(RECOVERED_2, logs=[other], sender=OTHER, to=SEPOLIA_XRESERVE) + receipt = eth.recover_source(plan, cp) + assert receipt.status == Status.ATTESTATION_PENDING and receipt.source_tx_id == RECOVERED + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(RECOVERED[2:]), 3) + assert receipt.protocol_state["nonce"] == "0x" + nonce.hex() and receipt.id == receipt.protocol_state["messageHash"] + + +def test_xreserve_multiple_matches_refuse_to_choose(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + hook = bytes(65) + cp = xreserve_checkpoint(plan, hook) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED, log_index=3) + w3.provider.history_logs.append(log) + w3.provider.add_receipt(RECOVERED, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + log2 = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED_2, log_index=1) + w3.provider.history_logs.append(log2) + w3.provider.add_receipt(RECOVERED_2, logs=[log2], sender=ACCT.address, to=SEPOLIA_XRESERVE) + with pytest.raises(BridgeError, match="Multiple matching xReserve deposits"): + eth.recover_source(plan, cp) + + +def test_xreserve_required_scan_needs_confirmed_approval(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + with pytest.raises(BridgeError, match="no confirmed approval block"): + eth.recover_source(plan, cp, required=True) + + +def test_xreserve_required_scan_with_no_matching_deposit_is_not_fatal(): + """Same ruling for xReserve: a completed scan (known sender, confirmed approval) that matches + nothing is a valid answer, not an inability to scan.""" + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(plan, cp, required=True) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + +def test_xreserve_private_recovery_uses_checkpointed_hook_and_wrapper_recipient(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address, mint_mode="private") + hook = encoding.xreserve_hook_data("private", ALEO, "testnet", "7scalar") + wrapper32 = encoding.aleo_address_to_bytes32(encoding.aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet")) + cp = xreserve_checkpoint(plan, hook) + cp = dataclasses.replace(cp, source={**cp.source, "transactionId": DISPATCH}) + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=wrapper32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=DISPATCH, log_index=2) + w3.provider.add_receipt(DISPATCH, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + receipt = eth.recover_source(plan, cp) + assert receipt.status == Status.ATTESTATION_PENDING and receipt.protocol_state["hookData"] == "0x" + hook.hex() + assert receipt.protocol_state["remoteRecipientBytes32"] == "0x" + wrapper32.hex() and receipt.protocol_state["mintMode"] == "private" + bad = dataclasses.replace(cp, source={**cp.source, "hookData": "0x02"}) + with pytest.raises(CheckpointInvalidError, match="hook data"): + eth.recover_source(plan, bad) + + +def test_log_scan_chunk_blocks_cannot_be_lowered_below_one(): + """``_scan_logs`` advances by ``chunk`` blocks a pass, so a 0 (or negative) chunk would spin + forever on a live range: the attribute validates on assignment, exactly like the constructor + (``ConfigurationError``, the error the constructor has always raised for this — the plan's + ``ValueError`` wording is kept as the same single check rather than two different errors).""" + eth, _ = mainnet_read_only() + for bad in (0, -5): + with pytest.raises(ConfigurationError, match="at least 1"): + eth.log_scan_chunk_blocks = bad + assert eth.log_scan_chunk_blocks == LOG_SCAN_CHUNK_BLOCKS # the refused assignments changed nothing + eth.log_scan_chunk_blocks = 10 + assert eth.log_scan_chunk_blocks == 10 + with pytest.raises(ConfigurationError, match="at least 1"): + EthModule(make_bridge(ethereum=Ethereum(w3=fake_web3())), Ethereum(w3=fake_web3()), log_scan_chunk_blocks=0) diff --git a/bridge-sdk/tests/test_eth_status.py b/bridge-sdk/tests/test_eth_status.py new file mode 100644 index 0000000..98c9d44 --- /dev/null +++ b/bridge-sdk/tests/test_eth_status.py @@ -0,0 +1,167 @@ +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.errors import (BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, + UnsupportedRouteError) +from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_web3 import deposited_log, dispatch_id_log, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO32 = encoding.aleo_address_to_bytes32(ALEO) +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +SEPOLIA_USDC, SEPOLIA_XRESERVE = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "0x008888878f94C0d87defdf0B07f46B93C1934442" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +H1, H2 = "0x" + "11" * 32, "0x" + "22" * 32 +MESSAGE_ID = bytes.fromhex("ab" * 32) +DELIVERED_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" +WBTC_ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") + + +def hyperlane_state(**overrides): + state = {"routeId": WBTC_ROUTE.id, "approvalTxIds": [H1], "sourceSender": ACCT.address, + "recipientBytes32": "0x" + ALEO32.hex(), "destinationDomain": 1634493807, + "nativeValueAtomic": "50000", "amountAtomic": "100000"} + state.update(overrides) + return state + + +def xreserve_state(**overrides): + state = {"routeId": USDC_ROUTE.id, "approvalTxIds": [H1], "sourceSender": ACCT.address, "mintMode": "public", + "intendedRecipient": ALEO, "xReserveContract": SEPOLIA_XRESERVE, "tokenAddress": SEPOLIA_USDC, + "sourceChainId": 11155111, "remoteDomain": 10002, "remoteRecipientBytes32": "0x" + ALEO32.hex(), + "hookData": "0x" + "00" * 65, "amountAtomic": "2000000", "maxFeeAtomic": "100000"} + state.update(overrides) + return state + + +def mainnet(*, signed=True, **config): + w3 = fake_web3(**config) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(ethereum=conn).eth, w3 + + +WBTC_PLAN = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) +USDC_PLAN = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + + +def test_approval_pending_branch(): + eth, w3 = mainnet() + receipt = Receipt(id=H1, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=hyperlane_state()) + assert eth.source_status(WBTC_PLAN, receipt) is receipt # no receipt yet → unchanged + w3.provider.add_receipt(H1) + advanced = eth.source_status(WBTC_PLAN, receipt) + assert advanced.status == Status.SOURCE_SUBMISSION_PENDING and advanced.id == H1 + w3.provider.add_receipt(H1, status=0) + failed = eth.source_status(WBTC_PLAN, receipt) + assert failed.status == Status.FAILED and failed.protocol_state["sourceError"] == f"EVM approval transaction reverted: {H1}" + assert "eth_sendRawTransaction" not in w3.provider.methods + + +def test_hyperlane_source_confirming_branch(): + eth, w3 = mainnet() + receipt = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=hyperlane_state()) + assert eth.source_status(WBTC_PLAN, receipt) is receipt + w3.provider.add_receipt(H2, logs=[dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=H2)], sender=ACCT.address, to=WBTC_ROUTER) + advanced = eth.source_status(WBTC_PLAN, receipt) + assert advanced.status == Status.DELIVERY_PENDING and advanced.id == Web3.to_hex(MESSAGE_ID) + assert advanced.source_tx_id == H2 and advanced.protocol_state["messageId"] == Web3.to_hex(MESSAGE_ID) + w3.provider.add_receipt(H2) # confirmed, no DispatchId log + no_id = eth.source_status(WBTC_PLAN, receipt) + assert no_id.status == Status.DELIVERY_PENDING and no_id.id == H2 and "messageId" not in no_id.protocol_state + w3.provider.add_receipt(H2, status=0) + failed = eth.source_status(WBTC_PLAN, receipt) + assert failed.status == Status.FAILED and failed.protocol_state["sourceError"] == f"EVM transaction reverted: {H2}" + + +def test_hyperlane_state_must_match_plan(): + eth, _ = mainnet() + bad = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=hyperlane_state(amountAtomic="1")) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared transfer"): + eth.source_status(WBTC_PLAN, bad) + wrong_route = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=hyperlane_state(routeId="hyperlane:ethereum/eth->aleo/eth")) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + eth.source_status(WBTC_PLAN, wrong_route) + missing_hash = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, protocol_state=hyperlane_state()) + with pytest.raises(CheckpointInvalidError, match="source transaction id"): + eth.source_status(WBTC_PLAN, missing_hash) + other = Receipt(id=H2, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=H2, protocol_state=hyperlane_state()) + with pytest.raises(BridgeError, match="SOURCE_APPROVAL_PENDING and SOURCE_CONFIRMING"): + eth.source_status(WBTC_PLAN, other) + + +def test_xreserve_source_confirming_branch_works_read_only(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth # no signer: uses sourceSender + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=xreserve_state()) + assert eth.source_status(USDC_PLAN, receipt) is receipt + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, + value=2_000_000, remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, + hook_data=bytes(65), tx_hash=H2, log_index=3) + w3.provider.add_receipt(H2, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + advanced = eth.source_status(USDC_PLAN, receipt) + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(H2[2:]), 3) + assert advanced.status == Status.ATTESTATION_PENDING and advanced.protocol_state["nonce"] == "0x" + nonce.hex() + assert advanced.id == advanced.protocol_state["messageHash"] and advanced.protocol_state["depositLogIndex"] == 3 + assert advanced.protocol_state["approvalTxIds"] == [H1] and advanced.source_tx_id == H2 + w3.provider.add_receipt(H2, status=0) + assert eth.source_status(USDC_PLAN, receipt).status == Status.FAILED + + +def test_xreserve_state_validation(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3, private_key=KEY)).eth + for bad in (xreserve_state(mintMode="private"), xreserve_state(intendedRecipient="aleo1" + "q" * 58), + xreserve_state(hookData="0x00"), xreserve_state(amountAtomic="abc")): + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=bad) + with pytest.raises(CheckpointInvalidError): + eth.source_status(USDC_PLAN, receipt) + no_owner = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=xreserve_state(sourceSender=None)) + plan_without_sender = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None) + read_only = make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth + with pytest.raises(ConfigurationError, match="prepared sender"): + read_only.source_status(plan_without_sender, no_owner) + + +def test_xreserve_source_confirming_asserts_the_chain_before_reading(): + """Mirrors the Hyperlane branch: a connection pointed at the wrong network must not read + receipts or logs from it.""" + w3 = fake_web3() # chain 1, route wants 11155111 + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=xreserve_state()) + with pytest.raises(ChainMismatchError): + eth.source_status(USDC_PLAN, receipt) + assert "eth_getTransactionReceipt" not in w3.provider.methods and "eth_getLogs" not in w3.provider.methods + + +def test_is_delivered_reads_mailbox(): + eth, w3 = mainnet(delivered={DELIVERED_ID}) + assert eth.is_delivered(DELIVERED_ID) is True + assert eth.is_delivered(bytes.fromhex(DELIVERED_ID[2:])) is True + assert eth.is_delivered("0x" + "00" * 32) is False + assert w3.provider.methods.count("eth_call") == 3 + with pytest.raises(BridgeError, match="32-byte message id"): + eth.is_delivered("0x1234") + testnet = make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth + with pytest.raises(UnsupportedRouteError, match="Mailbox"): + testnet.is_delivered(DELIVERED_ID) + + +def test_balance_native_and_erc20(): + eth, _ = mainnet(eth_balances={ACCT.address: 5}, token_balances={(WBTC, ACCT.address): 7}) + assert eth.balance("eth") == 5 and eth.balance("ethereum/wbtc") == 7 and eth.balance("usdt") == 0 + read_only, _ = mainnet(signed=False, eth_balances={ACCT.address: 5}) + with pytest.raises(ConfigurationError, match="address"): + read_only.balance("eth") + assert read_only.balance("eth", address=ACCT.address) == 5 diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py new file mode 100644 index 0000000..450c2ae --- /dev/null +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -0,0 +1,226 @@ +import dataclasses +import json + +import pytest +from eth_abi import decode +from eth_account import Account +from eth_utils import keccak +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.errors import (BridgeError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError, RouteUnavailableError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import DepositReceipt, Status +from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" +XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442" +OTHER = "0x0000000000000000000000000000000000000009" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +APPROVE = keccak(text="approve(address,uint256)")[:4].hex() +DEPOSIT = keccak(text="depositToRemote(uint256,uint32,bytes32,address,uint256,bytes)")[:4].hex() + + +def deposit_fields(tx): + """The DepositedToRemote fields the contract would emit for this depositToRemote calldata.""" + value, remote_domain, remote_recipient, local_token, max_fee, hook = decode( + ["uint256", "uint32", "bytes32", "address", "uint256", "bytes"], bytes.fromhex(tx["data"][10:])) + return {"local_token": Web3.to_checksum_address(local_token), "depositor": tx["from"], + "remote_recipient32": remote_recipient, "value": value, "remote_domain": remote_domain, + "remote_token32": REMOTE_TOKEN, "max_fee": max_fee, "hook_data": hook} + + +def deposit_logs(*, log_index=3, **overrides): + """Echo the depositToRemote calldata back as a DepositedToRemote log, optionally corrupting fields.""" + def logs(tx): + if tx["to"] != Web3.to_checksum_address(XRESERVE) or tx["data"][2:10] != DEPOSIT: + return [] + return [deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=log_index, **{**deposit_fields(tx), **overrides})] + return logs + + +def setup(*, allowance=0, logs=None, checkpoints=None): + w3 = fake_web3(chain_id=11155111, token_balances={(USDC, ACCT.address): 3_000_000}, + allowances={(USDC, ACCT.address, XRESERVE): allowance}) + w3.provider.receipt_logs = logs or deposit_logs() + bridge = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3, private_key=KEY), checkpoints=checkpoints) + return bridge.eth, w3 + + +def test_build_lists_approve_then_deposit_with_zero_value(): + eth, w3 = setup() + txs = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(USDC), Web3.to_checksum_address(XRESERVE)] + assert txs[0]["data"][2:10] == APPROVE and txs[1]["data"][2:10] == DEPOSIT + assert txs[0]["value"] == 0 and txs[1]["value"] == 0 and w3.provider.sent == [] + + +def test_record_mode_deposit_derives_nonce_payload_and_message_hash(): + eth, w3 = setup() + seen = [] + result = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").send(poll_seconds=0.001, on_checkpoint=seen.append) + assert isinstance(result, DepositReceipt) + sent = w3.provider.sent + assert len(sent) == 2 and sent[1]["value"] == 0 and sent[1]["to"] == Web3.to_checksum_address(XRESERVE) + assert sent[0]["data"][2:].lower() == APPROVE + XRESERVE[2:].lower().rjust(64, "0") + format(2_000_000, "064x") + receipt = result.receipt + assert receipt.status == Status.ATTESTATION_PENDING and receipt.protocol == "xreserve" + assert receipt.source_tx_id == w3.provider.hash_at(2) == result.transaction_id + hook = b"\x01" + bytes(64) + recipient32 = encoding.aleo_address_to_bytes32(ALEO) + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(w3.provider.hash_at(2)[2:]), 3) + payload = encoding.xreserve_deposit_payload(amount=2_000_000, remote_domain=10002, remote_token=REMOTE_TOKEN, + remote_recipient=recipient32, local_token=USDC, depositor=ACCT.address, + max_fee=100_000, nonce=nonce, hook_data=hook) + message_hash = "0x" + encoding.xreserve_message_hash(payload).hex() + assert len(payload) == 305 + assert receipt.id == message_hash == result.message_hash == receipt.protocol_state["messageHash"] + assert result.nonce == "0x" + nonce.hex() == receipt.protocol_state["nonce"] + assert receipt.protocol_state["payload"] == "0x" + payload.hex() + state = receipt.protocol_state + assert state["routeId"] == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and state["approvalTxIds"] == [w3.provider.hash_at(1)] + assert state["sourceSender"] == ACCT.address and state["mintMode"] == "record" and state["intendedRecipient"] == ALEO + assert state["xReserveContract"] == Web3.to_checksum_address(XRESERVE) and state["tokenAddress"] == Web3.to_checksum_address(USDC) + assert state["sourceChainId"] == 11155111 and state["sourceDomain"] == 0 and state["remoteDomain"] == 10002 + assert state["remoteRecipientBytes32"] == "0x" + recipient32.hex() and state["hookData"] == "0x" + hook.hex() + assert state["amountAtomic"] == "2000000" and state["maxFeeAtomic"] == "100000" and state["depositLogIndex"] == 3 + assert state["bridgeProgram"] == "test_usdcx_bridge_v2.aleo" and state["wrapperProgram"] == "shielded_usdcx_wrapper.aleo" + assert [cp.source for cp in seen] == [ + {"approvalTransactionIds": [w3.provider.hash_at(1)], "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2), "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2), "hookData": "0x" + hook.hex()}, + ] + assert seen[-1].id == message_hash and seen[-1].intent["mintMode"] == "record" + + +def test_sufficient_allowance_skips_approval(): + eth, w3 = setup(allowance=5_000_000) + result = eth.deposit_usdc(ALEO, amount_atomic=2_000_000).send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 and result.receipt.protocol_state["approvalTxIds"] == [] + assert result.receipt.protocol_state["hookData"] == "0x" + "00" * 65 + + +def test_private_mode_deposits_to_wrapper_and_never_persists_the_secret(): + eth, _ = setup(allowance=5_000_000) + seen = [] + result = eth.deposit_usdc(ALEO, amount="2", mint_mode="private", secret_nonce="7scalar").send( + poll_seconds=0.001, on_checkpoint=seen.append) + wrapper32 = encoding.aleo_address_to_bytes32(encoding.aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet")) + state = result.receipt.protocol_state + assert state["remoteRecipientBytes32"] == "0x" + wrapper32.hex() and state["intendedRecipient"] == ALEO + assert state["hookData"] == "0x" + encoding.xreserve_hook_data("private", ALEO, "testnet", "7scalar").hex() + assert "7scalar" not in json.dumps(state) and all("7scalar" not in cp.to_json() for cp in seen) + assert seen[0].source["hookData"].startswith("0x02") and len(seen[0].source["hookData"]) == 132 + + +def test_event_mismatch_or_absence_raises(): + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(value=1)) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(remote_token32=bytes(32))) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + eth, _ = setup(allowance=5_000_000, logs=lambda tx: []) + with pytest.raises(BridgeError, match="DepositedToRemote"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +@pytest.mark.parametrize("field, corrupted", [ + ("local_token", OTHER), + ("depositor", OTHER), + ("value", 1_999_999), + ("remote_domain", 10_003), + ("remote_recipient32", bytes(32)), + ("remote_token32", bytes(32)), + ("max_fee", 99_999), + ("hook_data", b"\x01" + bytes(64)), +]) +def test_every_re_verified_deposit_field_must_match(field, corrupted): + """All eight canonical DepositedToRemote fields are load-bearing: corrupting any one of them + alone must make the event stop counting as this transfer's deposit.""" + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(**{field: corrupted})) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +@pytest.mark.parametrize("ours_first", [True, False]) +def test_our_deposit_event_is_selected_among_other_accounts_deposits(ours_first): + """A batched transaction carries several accounts' deposits; ours is whichever event matches all + eight fields, not whichever happens to be last in the receipt.""" + def logs(tx): + if tx["to"] != Web3.to_checksum_address(XRESERVE) or tx["data"][2:10] != DEPOSIT: + return [] + fields = deposit_fields(tx) + ours = deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=3, **fields) + theirs = deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=7, **{**fields, "depositor": OTHER}) + return [ours, theirs] if ours_first else [theirs, ours] + + eth, _ = setup(allowance=5_000_000, logs=logs) + result = eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + assert result.receipt.status == Status.ATTESTATION_PENDING + assert result.receipt.protocol_state["depositLogIndex"] == 3 # ours, whatever the order + + +def test_timeouts_return_pending_receipts(): + eth, w3 = setup() + w3.provider.pending_nth.add(1) + result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.id == w3.provider.hash_at(1) and result.message_hash == "" and result.nonce == "" + assert len(w3.provider.sent) == 1 + eth, w3 = setup(allowance=5_000_000) + w3.provider.pending_nth.add(1) + seen = [] + result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) + assert [cp.source for cp in seen] == [{"transactionId": w3.provider.hash_at(1), "hookData": "0x" + "00" * 65}] + + +def test_plan_driven_deposit_is_identical_to_the_recipient_driven_one(): + eth, w3 = setup(allowance=5_000_000) + quote = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="record") + by_recipient = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").build() + by_plan = eth.deposit_usdc(plan=quote.plan).build() # mint_mode comes from the plan + assert by_plan == by_recipient and len(by_plan) == 1 and by_plan[0]["data"][2:10] == DEPOSIT + assert eth.quote_deposit_usdc(plan=quote.plan) == quote + assert w3.provider.sent == [] + + +def test_plan_driven_deposit_rejects_a_tampered_stale_or_foreign_plan(): + eth, w3 = setup(allowance=5_000_000) + plan = eth.quote_deposit_usdc(ALEO, amount="2").plan + w3.provider.methods.clear() + with pytest.raises(BridgeError, match="plan does not match the requested transfer: amount"): + eth.deposit_usdc(plan=dataclasses.replace(plan, amount_atomic=2_000_001)) + with pytest.raises(BridgeError, match="plan does not match the requested transfer: steps"): + eth.deposit_usdc(plan=dataclasses.replace(plan, mint_mode="private")) # steps still say "protocol" + with pytest.raises(BridgeError, match="plan does not match the requested transfer: mint_mode"): + eth.deposit_usdc(mint_mode="record", plan=plan) + with pytest.raises(RegistryVersionMismatchError): + eth.deposit_usdc(plan=dataclasses.replace(plan, registry_version="0000-00-00.stale")) + with pytest.raises(RouteUnavailableError, match="not a xreserve one"): + eth.deposit_usdc(plan=dataclasses.replace(plan, route_id="hyperlane:ethereum/eth->aleo/eth")) + with pytest.raises(ConfigurationError, match="does not match connected account"): + eth.deposit_usdc(plan=dataclasses.replace(plan, sender=OTHER)) + with pytest.raises(ValueError, match="not both"): + eth.quote_deposit_usdc(ALEO, amount="2", sender=ACCT.address, plan=plan) + assert w3.provider.methods == [] and w3.provider.sent == [] + + +def test_reverted_deposit_raises(): + eth, w3 = setup(allowance=5_000_000) + w3.provider.reverted_nth.add(1) + with pytest.raises(BridgeError, match="reverted"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +def test_deposit_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """Same guard as the quote path: a missing recipient is an InvalidRecipientError, not a TypeError.""" + eth, w3 = setup() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.deposit_usdc(amount="2") + assert w3.provider.methods == [] and w3.provider.sent == [] diff --git a/bridge-sdk/tests/test_eth_xreserve_quote.py b/bridge-sdk/tests/test_eth_xreserve_quote.py new file mode 100644 index 0000000..532defd --- /dev/null +++ b/bridge-sdk/tests/test_eth_xreserve_quote.py @@ -0,0 +1,152 @@ +import dataclasses + +import pytest +from eth_account import Account + +from aleo_bridge.encoding import aleo_address_to_bytes32, aleo_program_address, xreserve_hook_data +from aleo_bridge.errors import (BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, + InvalidAmountError, InvalidRecipientError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import EvmXReserveQuote +from tests.fakes.fake_web3 import fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" +SEPOLIA_XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442" +MAINNET_USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +MAINNET_XRESERVE = "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" + + +def sepolia(*, signed=True, balance=3_000_000, allowance=0, chain_id=11155111): + w3 = fake_web3(chain_id=chain_id, token_balances={(SEPOLIA_USDC, ACCT.address): balance}, + allowances={(SEPOLIA_USDC, ACCT.address, SEPOLIA_XRESERVE): allowance}) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(environment="testnet", ethereum=conn).eth, w3 + + +def test_record_mode_quote(): + eth, _ = sepolia() + q = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="record") + assert isinstance(q, EvmXReserveQuote) and q.kind == "evm-xreserve" + assert q.plan.route_id == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and q.plan.mint_mode == "record" + assert q.plan.amount == "2" and q.plan.amount_atomic == 2_000_000 and q.plan.sender == ACCT.address + assert q.amount_out == "2" and q.fees == () + assert q.hook_data == b"\x01" + bytes(64) + assert q.remote_recipient_bytes32 == aleo_address_to_bytes32(ALEO) + assert q.balance_atomic == 3_000_000 and q.allowance_atomic == 0 and q.approval_required is True + assert q.max_fee_atomic == 100_000 + + +def test_public_mode_is_default_and_allowance_can_cover(): + eth, _ = sepolia(allowance=5_000_000) + q = eth.quote_deposit_usdc(ALEO, amount_atomic=2_000_000) + assert q.plan.mint_mode == "public" and q.hook_data == bytes(65) and q.approval_required is False + + +def test_private_mode_targets_wrapper_program_and_commits_recipient(): + eth, _ = sepolia() + q = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="private", secret_nonce="7scalar") + wrapper = aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet") + assert q.remote_recipient_bytes32 == aleo_address_to_bytes32(wrapper) + assert q.hook_data[0] == 2 and len(q.hook_data) == 65 and q.hook_data[1:33] != bytes(32) + assert q.hook_data == xreserve_hook_data("private", ALEO, "testnet", "7scalar") + assert q.hook_data != eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="private").hook_data + assert q.plan.recipient == ALEO # the plan keeps the intended recipient, not the wrapper + + +def test_minimum_amount_and_balance_are_enforced(): + eth, _ = sepolia() + with pytest.raises(InvalidAmountError, match="minimum deposit is 2000000"): + eth.quote_deposit_usdc(ALEO, amount_atomic=1_999_999) + with pytest.raises(InsufficientBalanceError, match="USDC"): + eth.quote_deposit_usdc(ALEO, amount_atomic=3_000_001) + + +def test_wrong_chain_and_bad_mint_mode(): + eth, w3 = sepolia(chain_id=1) + with pytest.raises(ChainMismatchError, match="expected 11155111"): + eth.quote_deposit_usdc(ALEO, amount="2") + assert "eth_call" not in w3.provider.methods + eth, _ = sepolia() + with pytest.raises(BridgeError, match="mint_mode"): + eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="shielded") + + +def test_read_only_needs_explicit_sender(): + eth, _ = sepolia(signed=False) + with pytest.raises(ConfigurationError, match="sender"): + eth.quote_deposit_usdc(ALEO, amount="2") + q = eth.quote_deposit_usdc(ALEO, amount="2", sender=ACCT.address) + assert q.balance_atomic == 3_000_000 and q.plan.sender == ACCT.address + + +def test_mainnet_environment_selects_ethereum_route(): + w3 = fake_web3(chain_id=1, token_balances={(MAINNET_USDC, ACCT.address): 2_000_000}, + allowances={(MAINNET_USDC, ACCT.address, MAINNET_XRESERVE): 0}) + eth = make_bridge(environment="mainnet", ethereum=Ethereum(w3=w3, private_key=KEY)).eth + q = eth.quote_deposit_usdc(ALEO, amount="2") + assert q.plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and q.approval_required is True + + +def _corrupted_xreserve_route(**overrides): + route = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") + return dataclasses.replace(route, metadata={**route.metadata, **overrides}) + + +def test_corrupted_xreserve_contract_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(xReserveContract="not-an-address") + with pytest.raises(ConfigurationError, match="xReserveContract"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_non_hex_remote_token_bytes32_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteTokenBytes32="not-hex") + with pytest.raises(ConfigurationError, match="remoteTokenBytes32"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_short_remote_token_bytes32_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteTokenBytes32="0x" + "ab" * 16) # 16 bytes, not 32 + with pytest.raises(ConfigurationError, match="remoteTokenBytes32"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bridge_program_without_aleo_suffix_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(bridgeProgram="not_a_program") + with pytest.raises(ConfigurationError, match="bridgeProgram"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_negative_remote_domain_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteDomain=-1) + with pytest.raises(ConfigurationError, match="remoteDomain"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_non_digit_minimum_amount_atomic_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(minimumAmountAtomic="2_000_000") + with pytest.raises(ConfigurationError, match="minimumAmountAtomic"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_quote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """``recipient`` is only optional when ``plan=`` supplies it (the hook commits to it).""" + eth, w3 = sepolia() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.quote_deposit_usdc(amount="2") + assert w3.provider.methods == [] # refused before any contract read diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py new file mode 100644 index 0000000..9dbb670 --- /dev/null +++ b/bridge-sdk/tests/test_evm_call.py @@ -0,0 +1,383 @@ +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge._calls import EvmCall, EvmOutcome, EvmStep +from aleo_bridge._plan import build_plan +from aleo_bridge._evm_abi import ERC20_ABI, WARP_ROUTE_ABI +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import BridgeError, ConfigurationError +from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import DispatchReceipt, Receipt, Status +from tests.fakes.fake_web3 import fake_web3 + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +WBTC = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" +ROUTER = "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:ethereum/usdc->aleo/usdcx") + + +def make_call(w3, *, sender=None, store=None, approvals=1, steps=None): + conn = Ethereum(w3=w3, private_key=KEY) + plan = _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=100_000, recipient=ALEO, sender=sender) + token = w3.eth.contract(address=Web3.to_checksum_address(WBTC), abi=ERC20_ABI) + warp = w3.eth.contract(address=Web3.to_checksum_address(ROUTER), abi=WARP_ROUTE_ABI) + + def default_steps(owner): + out = [EvmStep("approve", token.address, token.encode_abi("approve", args=[warp.address, 100_000]), 0) + for _ in range(approvals)] + out.append(EvmStep("main", warp.address, warp.encode_abi("transferRemote", args=[1634493807, b"\x11" * 32, 100_000]), 50_000)) + return out + + def finish(outcome: EvmOutcome) -> DispatchReceipt: + status = Status.DELIVERY_PENDING if outcome.status == "CONFIRMED" else Status(outcome.status) + rid = outcome.source_tx_id or (outcome.approval_tx_ids[-1] if outcome.approval_tx_ids else "unsent") + receipt = Receipt(id=rid, protocol="hyperlane", status=status, source_tx_id=outcome.source_tx_id, + protocol_state={"routeId": ROUTE.id, "approvalTxIds": list(outcome.approval_tx_ids), + "sourceSender": outcome.sender}) + return DispatchReceipt(transaction_id=rid, route_id=ROUTE.id, message_id=None, amount_atomic=100_000, receipt=receipt) + + return EvmCall(conn, plan=plan, registry=DEFAULT_REGISTRY, steps=steps or default_steps, + finish=finish, store=store), plan + + +def test_plan_for_hyperlane_and_xreserve_shapes(): + plan = _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) + assert plan.route_id == ROUTE.id and plan.registry_version == DEFAULT_REGISTRY.version + assert plan.protocol == "hyperlane" and plan.environment == "mainnet" + assert plan.source_asset_id == "ethereum/wbtc" and plan.destination_asset_id == "aleo/wbtc" + assert plan.amount == "0.001" and plan.amount_atomic == 100_000 and plan.recipient == ALEO + assert plan.sender == ACCT.address and plan.mint_mode == "public" + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-approval", "approve", "evm-wallet", False), + ("source-dispatch", "dispatch", "evm-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + eth_plan = _plan_for(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth"), + amount_atomic=100, recipient=ALEO, sender=None) + assert [s.id for s in eth_plan.steps][0] == "source-dispatch" # native: no approval step + assert eth_plan.amount == "0.0000000000000001" + private = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None, mint_mode="private") + assert private.amount == "2" and private.mint_mode == "private" + assert [(s.id, s.executor, s.irreversible) for s in private.steps] == [ + ("source-approval", "evm-wallet", False), ("source-deposit", "evm-wallet", True), + ("deposit-attestation", "protocol", False), ("destination-mint", "aleo-wallet", False), + ] + public = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None) + assert public.steps[-1].executor == "protocol" + with pytest.raises(BridgeError, match="mint_mode"): + _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=1, recipient=ALEO, sender=None, mint_mode="private") + + +def test_build_plan_is_the_shared_builder_eth_re_exports(): + from aleo_bridge import _plan, eth + + assert eth._plan_for is _plan.build_plan + + +def test_build_plan_derives_the_wallet_executor_from_the_source_chain_family(): + """Ethereum-origin plans are identical to the ones the hard-coded "evm-wallet" used to produce.""" + eth_plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth"), + amount_atomic=100, recipient=ALEO, sender=ACCT.address) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in eth_plan.steps] == [ + ("source-dispatch", "dispatch", "evm-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + usdc_plan = build_plan(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in usdc_plan.steps] == [ + ("source-approval", "approve", "evm-wallet", False), + ("source-deposit", "deposit", "evm-wallet", True), + ("deposit-attestation", "wait-attestation", "protocol", False), + ("destination-mint", "mint", "protocol", False), + ] + + +def test_build_plan_uses_a_solana_wallet_for_a_solana_origin(): + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:solana/sol->aleo/sol"), + amount_atomic=1, recipient=ALEO, sender="11111111111111111111111111111111") + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-dispatch", "dispatch", "solana-wallet", True), # SOL is native: no approval step + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + +def test_build_plan_generalizes_xreserve_to_an_aleo_origin_burn(): + """Aleo-origin xReserve withdrawal is a different step shape than the EVM-origin deposit.""" + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("xreserve:aleo/usdcx->ethereum/usdc"), + amount_atomic=10_000_000, recipient=WBTC, sender=None) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-burn", "burn", "aleo-wallet", True), + ("withdrawal-attestation", "wait-attestation", "protocol", False), + ("destination-withdrawal", "withdraw", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + +def test_build_plan_skips_approval_for_aleo_token_sources_over_hyperlane(): + """Aleo ARC-20 tokens (wBTC/USDT mirrored on Aleo) need no approval, unlike their EVM counterparts.""" + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:aleo/wbtc->ethereum/wbtc"), + amount_atomic=1, recipient=WBTC, sender=None) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-dispatch", "dispatch", "aleo-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + +def test_build_returns_unsigned_dicts_in_order_without_sending(): + w3 = fake_web3() + call, _ = make_call(w3) + txs = call.build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] + assert [t["value"] for t in txs] == [0, 50_000] + assert [t["nonce"] for t in txs] == [0, 1] + assert all(t["from"] == ACCT.address and t["chainId"] == 1 for t in txs) + assert txs[1]["data"].startswith("0x") and w3.provider.sent == [] + + +def test_send_runs_approve_then_main_and_checkpoints_each_hash_before_polling(): + w3 = fake_web3() + # Make both receipts resolve only after a couple of pending polls, so the ordering + # test actually exercises "checkpoint fires, THEN polling happens" rather than a + # same-tick resolution that would pass even if the code checkpointed after polling. + w3.provider.receipt_delay_nth[1] = 2 + w3.provider.receipt_delay_nth[2] = 2 + call, plan = make_call(w3) + seen = [] + poll_counts_at_checkpoint = [] + + def on_checkpoint(cp): + seen.append(cp) + poll_counts_at_checkpoint.append(dict(w3.provider.receipt_poll_counts)) + + result = call.send(on_checkpoint=on_checkpoint, poll_seconds=0.001) + assert isinstance(result, DispatchReceipt) and result.receipt.status == Status.DELIVERY_PENDING + assert [t["to"] for t in w3.provider.sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] + assert w3.provider.sent[1]["value"] == 50_000 + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and result.receipt.source_tx_id == w3.provider.hash_at(2) + assert [cp.source for cp in seen] == [ + {"approvalTransactionIds": [w3.provider.hash_at(1)]}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2)}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2)}, + ] + assert all(cp.route == {"id": plan.route_id, "registryVersion": plan.registry_version} for cp in seen) + assert seen[0].intent["sender"] == ACCT.address + + # Checkpoint-before-poll ordering, per hash: + # cp0 (approval broadcast) fires before any eth_getTransactionReceipt for hash1. + assert poll_counts_at_checkpoint[0].get(w3.provider.hash_at(1), 0) == 0 + assert w3.provider.hash_at(2) not in poll_counts_at_checkpoint[0] + # cp1 (main broadcast) fires after hash1 was fully polled to confirmation, but + # before any eth_getTransactionReceipt for hash2. + assert poll_counts_at_checkpoint[1].get(w3.provider.hash_at(1), 0) > 0 + assert poll_counts_at_checkpoint[1].get(w3.provider.hash_at(2), 0) == 0 + # cp2 (confirmed) fires only after hash2 has itself been polled. + assert poll_counts_at_checkpoint[2].get(w3.provider.hash_at(2), 0) > 0 + # ... and each hash's poll count strictly increases after its own checkpoint fired + # (the receipt_delay=2 knob forces at least one more poll beyond the checkpoint tick). + assert w3.provider.receipt_poll_counts[w3.provider.hash_at(1)] > poll_counts_at_checkpoint[0].get(w3.provider.hash_at(1), 0) + assert w3.provider.receipt_poll_counts[w3.provider.hash_at(2)] > poll_counts_at_checkpoint[1].get(w3.provider.hash_at(2), 0) + + # Full RPC sequence: approve is sent and fully confirmed (>=1 receipt poll) before + # the main call is ever broadcast, and the main call is polled only afterwards. + relevant = [m for m in w3.provider.methods if m in ("eth_sendRawTransaction", "eth_getTransactionReceipt")] + first_send = relevant.index("eth_sendRawTransaction") + second_send = relevant.index("eth_sendRawTransaction", first_send + 1) + assert relevant[first_send] == "eth_sendRawTransaction" + between = relevant[first_send + 1:second_send] + assert between and all(m == "eth_getTransactionReceipt" for m in between) + after = relevant[second_send + 1:] + assert after and all(m == "eth_getTransactionReceipt" for m in after) + + +def test_approval_timeout_returns_pending_and_stops(): + w3 = fake_web3() + w3.provider.pending_nth.add(1) + call, _ = make_call(w3) + result = call.send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and len(w3.provider.sent) == 1 + + +def test_main_timeout_returns_source_confirming(): + w3 = fake_web3() + w3.provider.pending_nth.add(1) + call, _ = make_call(w3, approvals=0) + result = call.send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) + + +def test_wait_false_returns_after_first_broadcast(): + w3 = fake_web3() + call, _ = make_call(w3) + result = call.send(wait=False) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and len(w3.provider.sent) == 1 + + +def test_reverted_transaction_raises(): + w3 = fake_web3() + w3.provider.reverted_nth.add(1) + call, _ = make_call(w3) + with pytest.raises(BridgeError) as exc: + call.send(poll_seconds=0.001) + assert str(exc.value) == f"EVM transaction reverted: {w3.provider.hash_at(1)}" + + +def test_plan_sender_must_match_connected_account(): + w3 = fake_web3() + call, _ = make_call(w3, sender="0x0000000000000000000000000000000000000001") + with pytest.raises(ConfigurationError, match="does not match connected account"): + call.send() + assert w3.provider.sent == [] + + +def test_build_rejects_a_plan_sender_that_is_not_the_connected_account(): + w3 = fake_web3() + call, _ = make_call(w3, sender="0x0000000000000000000000000000000000000001") + with pytest.raises(ConfigurationError, match="does not match connected account"): + call.build() + assert w3.provider.sent == [] + + +class ExplodingStore: + """A checkpoint store whose disk is full / read-only.""" + + def __init__(self): + self.attempts = [] + + def save(self, checkpoint): + self.attempts.append(checkpoint) + raise OSError("read-only file system") + + def load(self, checkpoint_id): # pragma: no cover - never reached + return None + + def list(self): # pragma: no cover - never reached + return [] + + def delete(self, checkpoint_id): # pragma: no cover - never reached + return None + + +def test_store_failure_after_broadcast_reports_the_tx_hash_and_never_hides_it(): + """The transaction is already on the wire: the caller's callback must have run first, the + error must name the hash and the checkpoint, and no receipt poll may follow the failure.""" + w3 = fake_web3() + store = ExplodingStore() + call, _ = make_call(w3, store=store, approvals=0) + seen = [] + with pytest.raises(BridgeError) as exc: + call.send(on_checkpoint=seen.append, poll_seconds=0.001) + message = str(exc.value) + assert w3.provider.hash_at(1) in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [w3.provider.hash_at(1)] # callback ran before the store + assert [cp.id for cp in store.attempts] == [w3.provider.hash_at(1)] + assert len(w3.provider.sent) == 1 # broadcast happened exactly once + assert "eth_getTransactionReceipt" not in w3.provider.methods # nothing polled after the failure + + +def test_a_call_is_single_use_once_it_has_broadcast(): + """Re-sending the same call would spend the approval and dispatch a second transfer.""" + w3 = fake_web3() + call, _ = make_call(w3, approvals=0) + call.send(poll_seconds=0.001) + tx_hash = w3.provider.hash_at(1) + with pytest.raises(BridgeError) as exc: + call.send(poll_seconds=0.001) + assert str(exc.value) == (f"this call already broadcast {tx_hash}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") + assert len(w3.provider.sent) == 1 + + +def test_the_guard_is_armed_by_the_first_broadcast_of_a_multi_step_send(): + """The approval alone is enough: a resend would re-approve and re-dispatch.""" + w3 = fake_web3() + w3.provider.pending_nth.add(1) + call, _ = make_call(w3) # approval + main + call.send(timeout_seconds=0.01, poll_seconds=0.001) # stops pending after the approval + with pytest.raises(BridgeError, match=f"already broadcast {w3.provider.hash_at(1)}"): + call.send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 + + +def test_a_lost_send_response_also_arms_the_single_use_guard(): + w3 = fake_web3() + w3.provider.send_errors[1] = "connection reset by peer" + call, _ = make_call(w3, approvals=0) + with pytest.raises(BridgeError, match="may have been broadcast"): + call.send(poll_seconds=0.001) + with pytest.raises(BridgeError, match="already broadcast"): + call.send(poll_seconds=0.001) + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 + + +def test_a_failure_before_any_broadcast_leaves_the_call_usable(): + w3 = fake_web3() + failures = [] + + def steps(owner): + if not failures: + failures.append(owner) + raise BridgeError("router quote unavailable") # a read failed; nothing was sent + return [EvmStep("main", Web3.to_checksum_address(ROUTER), "0x", 0)] + + call, _ = make_call(w3, steps=steps) + with pytest.raises(BridgeError, match="router quote unavailable"): + call.send() + assert w3.provider.sent == [] + result = call.send(poll_seconds=0.001) # the guard was never armed + assert result.receipt.status == Status.DELIVERY_PENDING and len(w3.provider.sent) == 1 + + +def test_build_stays_repeatable_after_a_send(): + w3 = fake_web3() + call, _ = make_call(w3, approvals=0) + call.send(poll_seconds=0.001) + assert [t["to"] for t in call.build()] == [Web3.to_checksum_address(ROUTER)] # a preview never spends + assert len(w3.provider.sent) == 1 + + +def test_a_mismatched_echoed_hash_stops_the_call_before_any_checkpoint(): + """Checkpointing the node's hash would strand the real transaction under an id nobody can find.""" + w3 = fake_web3() + w3.provider.echo_hashes[1] = "0x" + "ab" * 32 + store = ExplodingStore() + call, _ = make_call(w3, store=store, approvals=0) + seen = [] + with pytest.raises(BridgeError) as exc: + call.send(on_checkpoint=seen.append, poll_seconds=0.001) + assert "0x" + "ab" * 32 in str(exc.value) and w3.provider.hash_at(1) in str(exc.value) + assert seen == [] and store.attempts == [] + assert "eth_getTransactionReceipt" not in w3.provider.methods + + +def test_a_mismatched_echoed_hash_also_arms_the_single_use_guard(): + """The node answered, so the signed bytes may be in its mempool under the hash we computed — + the same ambiguity as a lost response, and the same reason not to resend.""" + w3 = fake_web3() + w3.provider.echo_hashes[1] = "0x" + "ab" * 32 + call, _ = make_call(w3, approvals=0) + with pytest.raises(BridgeError, match="echoed transaction hash"): + call.send(poll_seconds=0.001) + local_hash = w3.provider.hash_at(1) + with pytest.raises(BridgeError) as exc: + call.send(poll_seconds=0.001) + assert str(exc.value) == (f"this call already broadcast {local_hash}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 + + +def test_bound_store_saves_every_checkpoint(tmp_path): + w3 = fake_web3() + store = FileCheckpointStore(tmp_path) + call, _ = make_call(w3, store=store) + result = call.send(poll_seconds=0.001) + ids = {cp.id for cp in store.list()} + assert w3.provider.hash_at(1) in ids and result.receipt.id in ids diff --git a/bridge-sdk/tests/test_execute.py b/bridge-sdk/tests/test_execute.py new file mode 100644 index 0000000..ced7214 --- /dev/null +++ b/bridge-sdk/tests/test_execute.py @@ -0,0 +1,325 @@ +"""``lifecycle.execute`` — the verb that commits funds on the source chain. + +The invariants under test: every source leg is dispatched through the module's own ``plan=`` +surface (never a re-derived asset/recipient/amount), an Aleo leg proves → checkpoints the exact +transaction → broadcasts → checkpoints the id (so a crash between proving and broadcast is +recoverable), and every boundary reaches the caller's callback once and the bound store once. +""" +import dataclasses +import json + +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import ConfigurationError, RouteUnavailableError +from aleo_bridge.lifecycle import execute, prepare +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +PREPARED_TX = {"preparedTransaction": {"transactionId": "at1fake1", + "serializedTransaction": json.dumps( + {"type": "execute", "id": "at1fake1", "fee": {}})}} + + +def _aleo_eth_plan(b, recipient=EVM_ADDRESS): + return prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=recipient) + + +def _usdc_plan(b, **kw): + return prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, **kw) + + +def _wbtc_plan(b, **kw): + return prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, **kw) + + +def _sol_plan(b, **kw): + return prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, **kw) + + +# ── Aleo-origin legs ────────────────────────────────────────────────────────── + +def test_aleo_hyperlane_checkpoints_prepared_tx_before_broadcast_and_requotes_igp(): + b = FakeBridge(ethereum=False) + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append) + + assert b.calls[0] == ("hyperlane.quote_gas_payment", "aleo/eth") # re-quoted at the last moment + assert b.calls[1][1]["gas_payment_microcredits"] == 8_174_147 + assert b.calls[1][1]["as_signer"] is False and b.calls[1][1]["amount_atomic"] == 1 + kinds = [e[0] for e in b.events] + assert kinds == ["delegate_prepared", "checkpoint:SOURCE_SUBMISSION_PENDING", "submit", + "checkpoint:SOURCE_CONFIRMING"] + assert b.events[2][2] is False # submit_prepared(wait=False) + assert cps[0].source == PREPARED_TX + assert cps[1].source == {"transactionId": "at1fake1"} + assert "deliveryVerification" not in cps[0].to_dict() # no ETH connection → no baseline + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + assert progress.receipt.source_tx_id == "at1fake1" + assert progress.receipt.protocol_state["routeId"] == plan.route_id + + +def test_aleo_hyperlane_pinned_gas_and_signer_mode_and_local_proving(): + b = FakeBridge(ethereum=False) + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, mode="signer", proving="local") + assert b.calls[0][0] == "hyperlane.transfer_remote" # no quote call + assert b.calls[0][1]["gas_payment_microcredits"] == 1 and b.calls[0][1]["as_signer"] is True + assert [e[0] for e in b.events][0] == "prove" + with pytest.raises(ConfigurationError, match="proving"): + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, proving="wallet") + with pytest.raises(ConfigurationError, match="mode"): + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, mode="private") + + +def test_aleo_hyperlane_captures_destination_balance_baseline_for_own_recipient(): + b = FakeBridge() # ethereum configured, address == recipient + b.eth.balances["ethereum/eth"] = 100 + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, gas_payment_microcredits=1, on_checkpoint=cps.append) + # read before the dispatch is even built, so the pre-broadcast checkpoint can carry it + assert b.calls[0] == ("eth.balance", "ethereum/eth") + assert b.calls[1][0] == "hyperlane.transfer_remote" + assert cps[0].delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + assert progress.receipt.protocol_state["destinationBalanceBeforeAtomic"] == "100" + assert progress.receipt.protocol_state["expectedDestinationIncreaseAtomic"] == "1" + # a recipient that is not our connection's address gets no baseline (we cannot read its balance) + b2 = FakeBridge() + cps2 = [] + execute(b2, _aleo_eth_plan(b2, recipient="0x0000000000000000000000000000000000000002"), + gas_payment_microcredits=1, on_checkpoint=cps2.append) + assert cps2[0].delivery_verification is None and ("eth.balance", "ethereum/eth") not in b2.calls + + +def test_aleo_xreserve_burn_modes_and_private_inputs(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.5", + recipient=EVM_ADDRESS) + cps = [] + progress = execute(b, plan, record="{ owner: aleo1..., amount: 3000000u128.private }", + merkle_proof="[{ siblings: [...], leaf_index: 1u32 }, { ... }]", on_checkpoint=cps.append) + assert b.calls[0][0] == "xreserve.burn" + assert b.calls[0][1]["mode"] == "private" and b.calls[0][1]["amount_atomic"] == 2_500_000 + assert b.calls[0][1]["record"].startswith("{ owner") and b.calls[0][1]["merkle_proof"].startswith("[") + assert [c.source for c in cps] == [PREPARED_TX, {"transactionId": "at1fake1"}] + assert progress.receipt.protocol_state["burnMode"] == "private" + b2 = FakeBridge(ethereum=False) + execute(b2, plan, mode="public-as-signer") + assert b2.calls[0][1]["mode"] == "public-as-signer" + with pytest.raises(ConfigurationError, match="mode"): + execute(b2, plan, mode="signer") + + +# ── EVM- and Solana-origin legs ─────────────────────────────────────────────── + +def test_evm_hyperlane_forwards_intermediate_checkpoints_and_polling_controls(): + b = FakeBridge() + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + approval = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM_ADDRESS}) + b.eth.intermediates = [approval] + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append, poll_seconds=2.0, timeout_seconds=300.0) + assert b.calls == [("eth.transfer_remote", {"plan": plan})] # the module re-derives nothing + assert ("evm_send", 300.0, 2.0) in b.events + assert [c.source for c in cps] == [{"approvalTransactionIds": ["0x" + "11" * 32]}, + {"transactionId": "0x" + "aa" * 32}] + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + + +def test_evm_xreserve_passes_mint_mode_and_secret_nonce(): + b = FakeBridge() + plan = _usdc_plan(b, mint_mode="private") + progress = execute(b, plan, secret_nonce="7scalar") + assert b.calls[0] == ("eth.deposit_usdc", {"plan": plan, "secret_nonce": "7scalar"}) + assert progress.receipt.status is Status.ATTESTATION_PENDING and progress.next == "wait" + assert progress.receipt.protocol_state["mintMode"] == "private" + assert "secretNonce" not in json.dumps(progress.receipt.protocol_state) + assert "7scalar" not in json.dumps(progress.receipt.protocol_state) + + +def test_a_private_mint_without_a_secret_nonce_is_refused_before_any_rpc(): + """``secret_nonce`` is not a plan field and the SDK never stores it: a private deposit that + silently fell back to "0scalar" would mint to a commitment the caller cannot ever reproduce.""" + b = FakeBridge() + with pytest.raises(ConfigurationError, match="secret_nonce"): + execute(b, _usdc_plan(b, mint_mode="private")) + assert b.calls == [] + execute(b, _usdc_plan(b)) # public mint: the default is fine + assert b.calls[0][1]["secret_nonce"] == "0scalar" + b2 = FakeBridge() + execute(b2, _usdc_plan(b2, mint_mode="record")) + assert b2.calls[0][1]["secret_nonce"] == "0scalar" + + +def test_solana_hyperlane(): + b = FakeBridge(solana=True) + plan = _sol_plan(b, sender=SOL_ADDRESS) + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append) + assert b.calls == [("sol.transfer_remote", {"plan": plan})] + assert cps[-1].source["blockhash"] == "recent" and cps[-1].source["lastValidBlockHeight"] == "123456789" + assert progress.receipt.status is Status.SOURCE_CONFIRMING + + +# ── refusals ────────────────────────────────────────────────────────────────── + +def test_sender_mismatch_missing_connection_and_unavailable_route(): + b = FakeBridge() + with pytest.raises(ConfigurationError, match="sender"): + execute(b, _wbtc_plan(b, sender="0x0000000000000000000000000000000000000009")) + with pytest.raises(ConfigurationError, match="Solana connection"): + execute(b, _sol_plan(b)) + with pytest.raises(RouteUnavailableError): + execute(b, prepare(b.registry, source="aleo/aleo", destination="ethereum/aleo", amount="1", + recipient=EVM_ADDRESS)) + no_eth = FakeBridge(ethereum=False) # bridge.ethereum is None → bridge.eth must not be touched + with pytest.raises(ConfigurationError, match="Ethereum connection"): + execute(no_eth, _wbtc_plan(no_eth)) + assert b.calls == [] and no_eth.calls == [] + + +def test_the_sender_check_is_case_insensitive_for_evm_and_exact_for_solana(): + """EVM addresses are hex (checksum casing is cosmetic); Solana addresses are base58, where a + case change is a different account entirely.""" + b = FakeBridge() + b.ethereum.address = b.eth.address = "0xAbC0000000000000000000000000000000000001" + execute(b, _wbtc_plan(b, sender="0xabc0000000000000000000000000000000000001")) + assert b.calls[0][0] == "eth.transfer_remote" + + mixed = "So11111111111111111111111111111111111111112" + s = FakeBridge(solana=True) + s.solana.address = s.sol.address = mixed + with pytest.raises(ConfigurationError, match="sender"): + execute(s, _sol_plan(s, sender=mixed.lower())) + assert s.calls == [] + execute(s, _sol_plan(s, sender=mixed)) + assert s.calls[0][0] == "sol.transfer_remote" + + +# ── the bound checkpoint store ──────────────────────────────────────────────── + +def test_bound_store_saves_every_checkpoint_and_replaces_superseded_ids(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(checkpoints=store) + plan = _usdc_plan(b) + approval = Receipt(id="0x" + "11" * 32, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + b.eth.intermediates = [approval] + execute(b, plan) + saved = store.list() + assert [c.id for c in saved] == ["0x" + "cc" * 32] # approval file replaced by the deposit's + assert saved[0].source == {"transactionId": "0x" + "bb" * 32, "hookData": "0x" + "00" * 65} + + +def test_module_and_lifecycle_checkpoint_channels_do_not_double_write(tmp_path): + """The module saves the checkpoints it emits and ``execute`` emits the final receipt again: + the caller still sees each boundary once and the store ends with one record per transfer.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(checkpoints=store) + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + b.eth.intermediates = [Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]})] + cps = [] + execute(b, plan, on_checkpoint=cps.append) + assert [c.id for c in cps] == ["0x" + "11" * 32, "0x" + "aa" * 32] # no repeated boundary + saved = store.list() + assert [c.id for c in saved] == ["0x" + "aa" * 32] + assert store.load("0x" + "aa" * 32).source == {"transactionId": "0x" + "aa" * 32} + + +def test_a_stale_plan_is_refused_before_anything_is_sent(): + b = FakeBridge() + plan = dataclasses.replace(_wbtc_plan(b, sender=EVM_ADDRESS), registry_version="0000-00-00.stale") + with pytest.raises(Exception, match="registry"): + execute(b, plan) + assert b.calls == [] and b.events == [] + + +# ── review carry-overs (items 6-8) ──────────────────────────────────────────── + +def test_persist_never_leaves_the_store_empty_between_checkpoints(tmp_path): + """Item 6: ``_persist`` saves the new checkpoint before deleting the superseded id, and a + module-emitted checkpoint's own supersede is deferred (parked on the ``_Emitter``) until the + module has actually saved it — otherwise a crash between delete and save loses the record.""" + store = FileCheckpointStore(tmp_path) + observed = [] + orig_save, orig_delete = store.save, store.delete + + def save(cp): + orig_save(cp) + observed.append(len(store.list())) + + def delete(cid): + orig_delete(cid) + observed.append(len(store.list())) + + store.save, store.delete = save, delete + b = FakeBridge(checkpoints=store) + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + b.eth.intermediates = [Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]})] + execute(b, plan) + assert observed and all(n >= 1 for n in observed) # never empty in between + assert [c.id for c in store.list()] == ["0x" + "aa" * 32] # exactly one record after execute + + +def test_aleo_hyperlane_leg_refuses_a_sender_mismatch_before_proving(): + """Item 7: an Aleo leg checks the plan's sender against ``bridge.aleo_address()`` before + proving anything.""" + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS, + sender="aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqvfnl2t") + with pytest.raises(ConfigurationError, match="sender"): + execute(b, plan, gas_payment_microcredits=1) + assert b.calls == [] and "delegate_prepared" not in [e[0] for e in b.events] + + +def test_aleo_xreserve_leg_refuses_a_sender_mismatch_before_proving(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.5", + recipient=EVM_ADDRESS, + sender="aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqvfnl2t") + with pytest.raises(ConfigurationError, match="sender"): + execute(b, plan, mode="public") + assert b.calls == [] and "delegate_prepared" not in [e[0] for e in b.events] + + +def test_aleo_leg_with_no_sender_pinned_and_no_configured_account_is_unaffected(): + """A plan without a sender, or a bridge whose ``aleo_address()`` cannot be read, never blocks + the leg — the check is a no-op when there is nothing to compare.""" + b = FakeBridge(ethereum=False) + + def raises(*a, **kw): + raise ConfigurationError("no aleo account configured") + + b.aleo_address = raises + execute(b, _aleo_eth_plan(b)) + assert b.calls[0][0] == "hyperlane.quote_gas_payment" + + +def test_destination_balance_baseline_read_is_best_effort(monkeypatch): + """Item 8: an advisory destination-balance read never blocks funds movement.""" + b = FakeBridge() + b.eth.balances["ethereum/eth"] = 100 + + def raise_balance(asset): + raise RuntimeError("RPC is down") + + monkeypatch.setattr(b.eth, "balance", raise_balance) + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, gas_payment_microcredits=1, on_checkpoint=cps.append) + assert cps[0].delivery_verification is None + assert "destinationBalanceBeforeAtomic" not in progress.receipt.protocol_state diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py new file mode 100644 index 0000000..8df793c --- /dev/null +++ b/bridge-sdk/tests/test_freezelist.py @@ -0,0 +1,166 @@ +import pytest + +from aleo_bridge import freezelist as fl +from aleo_bridge.errors import ConfigurationError + +# Vectors from sdk/src/integrations/sealance/merkle-tree.ts docstrings +A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +B = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t" +A_FIELD = 3501665755452795161867664882580888971213780722176652848275908626939553697821 +B_FIELD = 1295133970529764960316948294624974168921228814652993007266766481909235735940 +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ZERO = fl.ZERO_ADDRESS +PROGRAM = "usdcx_stablecoin.aleo" # the TOKEN program, as callers (privacy.py, xreserve.py) pass it +FREEZE_LIST_PROGRAM = "usdcx_freezelist.aleo" # the program that actually holds the freeze list +EMPTY_ONE = "{ siblings: [" + ", ".join(["0field"] * 16) + "], leaf_index: 1u32 }" + + +def test_empty_pair_literal_matches_veil(): + assert fl.EMPTY_MERKLE_PROOF_PAIR == f"[{EMPTY_ONE}, {EMPTY_ONE}]" + assert fl.EMPTY_MERKLE_PROOF_PAIR.count("0field") == 32 and fl.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 + + +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", "mainnet") == expected + assert expected.endswith("field") and expected != "0field" + assert fl.hash_two("0field", "3field", "4field", "mainnet") != 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"], "mainnet") == [0, 0, fl.EMPTY_TREE_ROOT] + assert fl.EMPTY_TREE_ROOT == 3642222252059314292809609689035560016959342421640560347114299934615987159853 + + +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(ConfigurationError, 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"], "mainnet") + four = fl.build_tree(["1field", "2field", "3field", "4field"], "mainnet") + 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", "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"): + fl.build_tree([], "mainnet") + with pytest.raises(ConfigurationError, match="even number"): + fl.build_tree(["1field", "2field", "3field"], "mainnet") + + +def test_leaf_indices_and_sibling_path(): + tree = fl.build_tree(fl.generate_leaves([A]), "mainnet") # leaves [0, A_FIELD] + assert fl.leaf_indices(tree, RECIPIENT) == (1, 1) # RECIPIENT's field > A_FIELD → clamps to the last leaf + assert fl.leaf_indices(tree, A) == (0, 1) # equal to leaf 1 → (0, 1) + four = fl.build_tree(["1field", "2field", "3field", "4field"], "mainnet") + 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 }]" + + +def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): + tree = fl.build_tree(["0field", "0field"], "mainnet") + left, right = fl.leaf_indices(tree, RECIPIENT) + assert (left, right) == (1, 1) + proof = fl.format_merkle_proof((fl.sibling_path(tree, left, fl.PROOF_SIBLINGS), left), + (fl.sibling_path(tree, right, fl.PROOF_SIBLINGS), right)) + assert proof == fl.EMPTY_MERKLE_PROOF_PAIR + + +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. + 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 + 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"} + 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}]" + with pytest.raises(ConfigurationError, match="freeze list"): + bridge.freezelist.exclusion_proof(A, PROGRAM) + + +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"): + bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + with pytest.raises(ConfigurationError, match="unreadable"): + bridge.freezelist.tree(PROGRAM) + + +def test_freezelist_last_index_parsing_is_not_rstrip(bridge): + mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) + mappings["freeze_list_last_index"] = {"true": "12u32"} + mappings["freeze_list_index"] = {f"{i}u32": ZERO for i in range(12)} | {"12u32": B} + assert bridge.freezelist.leaves(PROGRAM) == [B] + + +def test_freeze_list_program_resolves_via_imports(bridge): + bridge.aleo.imports["usdcx_stablecoin.aleo"] = ["credits.aleo", FREEZE_LIST_PROGRAM] + assert bridge.freezelist.freeze_list_program(PROGRAM) == FREEZE_LIST_PROGRAM + + +def test_freeze_list_program_resolves_via_fallback_table_when_imports_unavailable(bridge): + # default fixture sets no imports for usdcx_stablecoin.aleo -> falls back to the static table + assert bridge.freezelist.freeze_list_program(PROGRAM) == FREEZE_LIST_PROGRAM + assert bridge.freezelist.freeze_list_program("test_usdcx_stablecoin.aleo") == "test_usdcx_freezelist.aleo" + assert bridge.freezelist.freeze_list_program(FREEZE_LIST_PROGRAM) == FREEZE_LIST_PROGRAM # already a freezelist program + + +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") + + +def test_freeze_list_program_propagates_unrelated_exceptions(bridge, monkeypatch): + # Only ProgramNotFound/AleoError fall back to the static table; a transient failure of any + # other type (e.g. a network hiccup) must propagate, not be swallowed as "no imports". + def boom(program_id): + raise RuntimeError("rpc hiccup") + + monkeypatch.setattr(bridge, "program", boom) + with pytest.raises(RuntimeError, match="rpc hiccup"): + bridge.freezelist.freeze_list_program(PROGRAM) + + +def test_leaves_reads_from_freeze_list_program_not_token_program(bridge): + # the token program's own mappings are deliberately wrong, to prove they are never consulted + bridge.aleo.mappings[PROGRAM]["freeze_list_last_index"] = {"true": "0u32"} + bridge.aleo.mappings[PROGRAM]["freeze_list_index"] = {"0u32": A} + fl_mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) + fl_mappings["freeze_list_last_index"] = {"true": "1u32"} + fl_mappings["freeze_list_index"] = {"0u32": ZERO, "1u32": B} # index 0 is the zero-address sentinel + assert bridge.freezelist.leaves(PROGRAM) == [B] + + +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.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 diff --git a/bridge-sdk/tests/test_gen_context.py b/bridge-sdk/tests/test_gen_context.py new file mode 100644 index 0000000..033fef6 --- /dev/null +++ b/bridge-sdk/tests/test_gen_context.py @@ -0,0 +1,49 @@ +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GEN = ROOT / "codegen" / "gen_context.py" + + +def _render() -> str: + out = subprocess.run([sys.executable, str(GEN), "--stdout"], capture_output=True, text=True, cwd=ROOT) + assert out.returncode == 0, out.stderr + return out.stdout + + +def test_tier1_lifecycle_and_conversation_pattern(): + page = _render() + for verb in ("from_env", "from_profile", "status", "quote", "execute", "wait", "recover", "resume", + "complete", "pending"): + assert f"### `{verb}(" in page, verb + assert "## Serving a chatting user" in page + assert "NEVER ask the user to paste a private key" in page + assert "quote first" in page.lower() and "human units" in page.lower() + for nxt in ("`wait`", "`resume`", "`complete`", "`done`", "`failed`"): + assert nxt in page # progress.next table + assert "irreversible" in page.lower() + assert "secret_nonce" in page and "never stores" in page.lower() + + +def test_tier2_modules_and_registry_table(): + page = _render() + for method in ("hyperlane.transfer_remote", "hyperlane.quote_gas_payment", "xreserve.burn", + "xreserve.private_mint", "xreserve.get_attestation", "shield", "unshield", + "freezelist.exclusion_proof", "eth.transfer_remote", "eth.deposit_usdc", + "eth.quote_transfer_remote", "sol.transfer_remote", "sol.quote_transfer_remote"): + assert f"### `{method}(" in page, method + from aleo_bridge.registry import DEFAULT_REGISTRY + for route in DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None): + assert f"`{route.id}`" in page, route.id + assert "| metadata-required |" in page and "| active |" in page + + +def test_committed_pages_are_current(): + check = subprocess.run([sys.executable, str(GEN), "--check"], capture_output=True, text=True, cwd=ROOT) + assert check.returncode == 0, check.stderr or "AGENTS.md stale — run codegen/gen_context.py" + assert (ROOT / "AGENTS.md").read_text() == (ROOT / "python" / "aleo_bridge" / "AGENTS.md").read_text() + + +def test_page_stays_compact(): + assert len(_render()) < 40_000 diff --git a/bridge-sdk/tests/test_get_status.py b/bridge-sdk/tests/test_get_status.py new file mode 100644 index 0000000..fb847ae --- /dev/null +++ b/bridge-sdk/tests/test_get_status.py @@ -0,0 +1,291 @@ +import pytest + +from aleo_bridge.encoding import (xreserve_deposit_payload, xreserve_hook_data, xreserve_message_hash, + xreserve_nonce_from_payload) +from aleo_bridge.errors import (BridgeError, CheckpointInvalidError, ConfigurationError, + DeliveryUnknownError, UnsupportedRouteError) +from aleo_bridge.lifecycle import aleo_transaction_status, get_status, prepare +from aleo_bridge.types import Attestation, Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +SIG = "0x" + "11" * 65 + + +def _inbound_private(b): + plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT, mint_mode="private") + hook = xreserve_hook_data("private", ALEO_RECIPIENT, "testnet", "7scalar") + payload = xreserve_deposit_payload(amount=2_000_000, remote_domain=10_002, remote_token=b"\x11" * 32, + remote_recipient=b"\x22" * 32, + local_token="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + depositor=EVM_ADDRESS, max_fee=100_000, nonce=b"\x00" * 32, hook_data=hook) + message_hash = "0x" + xreserve_message_hash(payload).hex() + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, + source_tx_id="0x" + "22" * 32, + protocol_state={"routeId": plan.route_id, "mintMode": "private", + "intendedRecipient": ALEO_RECIPIENT, "payload": "0x" + payload.hex(), + "messageHash": message_hash, "bridgeProgram": "test_usdcx_bridge_v2.aleo"}) + return plan, payload, message_hash, receipt + + +def test_guards_and_terminal_passthrough(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + with pytest.raises(CheckpointInvalidError, match="does not match"): + get_status(b, plan, Receipt(id="x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": "other"})) + done = Receipt(id="x", protocol="hyperlane", status=Status.COMPLETED, protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, done) is done + failed = done.replace(status=Status.FAILED) + assert get_status(b, plan, failed) is failed and b.calls == [] and b.events == [] + expired = done.replace(status=Status.EXPIRED) + assert get_status(b, plan, expired) is expired and b.calls == [] and b.events == [] + + +def test_branch1_evm_approval_pending_delegates_to_eth_source_status(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT) + receipt = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + b.eth.source_status_result = receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + out = get_status(b, plan, receipt) + assert out.status is Status.SOURCE_SUBMISSION_PENDING and b.calls == [("eth.source_status", Status.SOURCE_APPROVAL_PENDING)] + + +@pytest.mark.parametrize("node_status,expected", [("accepted", Status.DELIVERY_PENDING), ("rejected", Status.FAILED)]) +def test_branch2_aleo_source_confirming_reads_confirmed_transaction(node_status, expected): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM_ADDRESS) + receipt = Receipt(id="at1burn", protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id="at1burn", + protocol_state={"routeId": plan.route_id}, next_action={"kind": "stale"}) + pending = get_status(b, plan, receipt) + assert pending is receipt # TransactionNotFound → unchanged + b.aleo.confirmed_transactions["at1burn"] = {"status": node_status, "type": "execute", "index": 3, + "transaction": {"id": "at1burn"}, "finalize": []} + out = get_status(b, plan, receipt) + assert out.status is expected and out.next_action is None + if expected is Status.FAILED: + assert "rejected" in out.protocol_state["sourceError"] + assert aleo_transaction_status(b, "at1burn")[0] == node_status + assert aleo_transaction_status(b, "at1unknown") == ("pending", None) + + +def test_branch3_4_hyperlane_source_confirming_evm_and_solana(): + b = FakeBridge(solana=True) + evm_plan = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + evm_receipt = Receipt(id="0x" + "aa" * 32, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="0x" + "aa" * 32, protocol_state={"routeId": evm_plan.route_id}) + b.eth.source_status_result = evm_receipt.replace(status=Status.DELIVERY_PENDING, + protocol_state={**evm_receipt.protocol_state, "messageId": "0x" + "cd" * 32}) + assert get_status(b, evm_plan, evm_receipt).protocol_state["messageId"] == "0x" + "cd" * 32 + sol_plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sol_receipt = Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": sol_plan.route_id}) + b.sol.source_status_result = sol_receipt.replace(status=Status.EXPIRED, + protocol_state={**sol_receipt.protocol_state, "blockhashExpired": True, + "sourceError": "Solana transaction expired before confirmation: sig"}) + assert get_status(b, sol_plan, sol_receipt).status is Status.EXPIRED + assert b.calls == [("eth.source_status", Status.SOURCE_CONFIRMING), ("sol.source_status", Status.SOURCE_CONFIRMING)] + + +def test_branch5_hyperlane_delivery_via_destination_mailbox(): + b = FakeBridge() + to_aleo = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + mid = "0x" + "cd" * 32 + receipt = Receipt(id=mid, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="0x" + "aa" * 32, + protocol_state={"routeId": to_aleo.route_id, "messageId": mid}) + assert get_status(b, to_aleo, receipt) is receipt + b.hyperlane.delivered[mid] = True + assert get_status(b, to_aleo, receipt).status is Status.COMPLETED + assert b.calls[-1] == ("hyperlane.is_delivered", mid) + + to_evm = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + receipt2 = Receipt(id="at1x", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1x", + protocol_state={"routeId": to_evm.route_id, "messageId": mid}) + b.eth.delivered[mid] = True + assert get_status(b, to_evm, receipt2).status is Status.COMPLETED + assert b.calls[-1] == ("eth.is_delivered", mid) + + +def test_branch5_solana_delivery_pending_without_message_id_fills_from_logs(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=sig, + protocol_state={"routeId": plan.route_id, "messageIdUnavailable": True}) + + # the log read raises -> unchanged, never falls through to is_delivered with the signature + b.sol.transaction_logs_error = RuntimeError("solana RPC is down") + assert get_status(b, plan, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + b.sol.transaction_logs_error = None + + # the log read succeeds but carries no dispatch line -> still unavailable, unchanged + b.sol.transaction_logs_result = ["Program log: something unrelated"] + assert get_status(b, plan, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + + # the dispatch line is present -> message id filled in, then the destination Mailbox is checked + mid = "0x" + "cd" * 32 + b.sol.transaction_logs_result = [f"Program log: Dispatched message to 1399811149, ID {mid}"] + out = get_status(b, plan, receipt) + assert out.id == mid + assert out.protocol_state["messageId"] == mid + assert "messageIdUnavailable" not in out.protocol_state + assert out.status is Status.DELIVERY_PENDING + assert b.calls[-1] == ("hyperlane.is_delivered", mid) + + b.hyperlane.delivered[mid] = True + assert get_status(b, plan, receipt).status is Status.COMPLETED + + +def test_branch6_aleo_origin_balance_diff_fallback(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + b.sol.balance_lamports = 100 + assert get_status(b, plan, receipt) is receipt + b.sol.balance_lamports = 101 + assert get_status(b, plan, receipt).status is Status.COMPLETED + no_baseline = receipt.replace(protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, no_baseline) is no_baseline # branch 7: unchanged + b2 = FakeBridge(ethereum=False) + with pytest.raises(DeliveryUnknownError, match="destination balance"): + get_status(b2, plan, receipt) + + +def test_branch6_a_failing_destination_balance_read_propagates(): + """Carried from the Task 6 review (item 8): in branch 6 the destination balance is the DELIVERY + SIGNAL, not an advisory baseline. A transport failure must surface here so ``wait``'s transient + classifier can retry it — swallowing it would read as "not delivered yet" forever.""" + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", + recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, + source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + + def boom(): + raise BridgeError("Solana RPC request failed with HTTP status 429") + + b.sol.balance = boom + with pytest.raises(BridgeError, match="429"): + get_status(b, plan, receipt) + + +def test_branch8_and_9_xreserve_outbound_and_not_implemented(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM_ADDRESS) + receipt = Receipt(id="at1burn", protocol="xreserve", status=Status.DELIVERY_PENDING, source_tx_id="at1burn", + protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, receipt) is receipt + with pytest.raises(UnsupportedRouteError, match="not implemented"): + get_status(b, plan, receipt.replace(status=Status.ATTESTATION_PENDING)) + + +def test_branch10_nullifier_first_then_attestation_then_private_action(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + # nullifier read comes first for every inbound pending status, and derives the nonce from the payload + b.xreserve.delivered_nonces.add("0x" + xreserve_nonce_from_payload(payload).hex()) + waiting = receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": "aleo-testnet"}) + out = get_status(b, plan, waiting) + assert out.status is Status.COMPLETED and out.next_action is None + assert b.calls[0][0] == "xreserve.is_delivered" + b.xreserve.delivered_nonces.clear() + # stored nonce wins over the payload + stored = receipt.replace(status=Status.DELIVERY_PENDING, + protocol_state={**receipt.protocol_state, "nonce": "0x" + "33" * 32}) + b.xreserve.delivered_nonces.add("0x" + "33" * 32) + assert get_status(b, plan, stored).status is Status.COMPLETED + b.xreserve.delivered_nonces.clear() + # attestation pending → unchanged; complete + private → DESTINATION_ACTION_REQUIRED + assert get_status(b, plan, receipt) is receipt + b.xreserve.attestations[message_hash] = Attestation(payload=payload, message_hash=bytes.fromhex(message_hash[2:]), + attestation=bytes.fromhex(SIG[2:]), status="complete") + ready = get_status(b, plan, receipt) + assert ready.status is Status.DESTINATION_ACTION_REQUIRED + assert ready.next_action == {"kind": "xreserve-private-mint", "chainId": "aleo-testnet"} + assert ready.protocol_state["attestation"] == SIG + assert get_status(b, plan, ready) is ready # action required → unchanged + # public mode → DELIVERY_PENDING with attestation kept + public_plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT) + public = receipt.replace(protocol_state={**receipt.protocol_state, "mintMode": "public"}) + out = get_status(b, public_plan, public) + assert out.status is Status.DELIVERY_PENDING and out.protocol_state["attestation"] == SIG + with pytest.raises(CheckpointInvalidError, match="message hash"): + get_status(b, plan, receipt.replace(protocol_state={"routeId": plan.route_id})) + + +def test_branch10_source_confirming_and_destination_confirming(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + confirming = receipt.replace(status=Status.SOURCE_CONFIRMING) + b.eth.source_status_result = receipt + assert get_status(b, plan, confirming) is receipt and b.calls[-1][0] == "eth.source_status" + minting = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id="at1private", + protocol_state={**receipt.protocol_state, "attestation": SIG}) + assert get_status(b, plan, minting) is minting + b.aleo.confirmed_transactions["at1private"] = {"status": "accepted", "type": "execute"} + assert get_status(b, plan, minting).status is Status.COMPLETED + b.aleo.confirmed_transactions["at1private"] = {"status": "rejected", "type": "execute", "rejected": {"type": "execution"}} + out = get_status(b, plan, minting) + assert out.status is Status.FAILED and "at1private" in out.protocol_state["destinationError"] + with pytest.raises(CheckpointInvalidError, match="destination transaction id"): + get_status(b, plan, minting.replace(destination_tx_id=None)) + + +def test_branch10_delivered_wins_over_attestation_pending(): + # Carried from the Task 5 review (item 7): the destination nullifier check (invariant 6) must + # run BEFORE the attestation read for EVERY inbound-pending status, including + # ATTESTATION_PENDING itself, not just DESTINATION_ACTION_REQUIRED/DELIVERY_PENDING — so a + # nonce that is already delivered short-circuits straight to COMPLETED even when a complete + # attestation is also scripted, and xreserve.get_attestation is never called. + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + nonce = "0x" + xreserve_nonce_from_payload(payload).hex() + b.xreserve.delivered_nonces.add(nonce) + b.xreserve.attestations[message_hash] = Attestation(payload=payload, message_hash=bytes.fromhex(message_hash[2:]), + attestation=bytes.fromhex(SIG[2:]), status="complete") + out = get_status(b, plan, receipt) + assert out.status is Status.COMPLETED + assert not any(call[0] == "xreserve.get_attestation" for call in b.calls) + + +def test_message_id_never_falls_back_to_source_tx_id(): + # Carried from the Task 5 review (item 8): a DispatchId log that could not be read leaves + # receipt.id == receipt.source_tx_id (the source transaction hash) — that must never be + # mistaken for the Hyperlane message id, even though it has the same 0x + 64-hex shape. + b = FakeBridge() + to_aleo = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + tx = "0x" + "aa" * 32 + receipt = Receipt(id=tx, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=tx, + protocol_state={"routeId": to_aleo.route_id}) + assert get_status(b, to_aleo, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + + +def test_solana_message_id_fill_in_raises_on_missing_connection(): + # Carried from the Task 5 review (item 9): resolving the Solana connection must happen OUTSIDE + # the log-read try/except, so a missing connection surfaces as ConfigurationError instead of + # being swallowed as "still unavailable". + b = FakeBridge(ethereum=False, solana=False) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=sig, + protocol_state={"routeId": plan.route_id, "messageIdUnavailable": True}) + with pytest.raises(ConfigurationError, match="Solana"): + get_status(b, plan, receipt) diff --git a/bridge-sdk/tests/test_hyperlane.py b/bridge-sdk/tests/test_hyperlane.py new file mode 100644 index 0000000..5702e10 --- /dev/null +++ b/bridge-sdk/tests/test_hyperlane.py @@ -0,0 +1,204 @@ +import pytest + +from aleo_bridge import hyperlane as hl +from aleo_bridge.errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteUnavailableError, UnsupportedRouteError) +from aleo_bridge.registry import DEFAULT_REGISTRY as REG +from aleo_bridge.types import DispatchReceipt, Status +from tests.conftest import ETH_GAS_CONFIG, IGP_KEY_ETH + +EVM1 = "0x0000000000000000000000000000000000000001" +SOL_SYSTEM = "11111111111111111111111111111111" +ZERO = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +MAILBOX_STATE = ("{ default_hook: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, " + "required_hook: aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82 }") +ROUTES = [ # veil test/actions/aleoHyperlane.test.ts ROUTES (active four); amount "1" in source decimals + ("aleo/eth", "hyperlane:aleo/eth->ethereum/eth", "hyp_warp_token_eth_v2.aleo", EVM1, 10**18), + ("aleo/wbtc", "hyperlane:aleo/wbtc->ethereum/wbtc", "hyp_warp_token_wbtc_v2.aleo", EVM1, 10**8), + ("aleo/usdt", "hyperlane:aleo/usdt->ethereum/usdt", "hyp_warp_token_usdt_v2.aleo", EVM1, 10**6), + ("aleo/sol", "hyperlane:aleo/sol->solana/sol", "hyp_warp_token_sol_v2.aleo", SOL_SYSTEM, 10**9), +] + + +def _inputs(bridge, route_id, recipient, amount, gas=8174147): + route = REG.route(route_id) + src, dst = REG.asset(route.source_asset_id), REG.asset(route.destination_asset_id) + return bridge.hyperlane.build_transfer_remote_inputs( + route, recipient=recipient, amount_atomic=amount, gas_payment_microcredits=gas, + decimals=(route.metadata["aleoLocalDecimals"], route.metadata["aleoRemoteDecimals"])) + + +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"): + hl.parse_gas_config("{ gas_overhead: 1u128 }") + assert hl.gas_config_key(REG.route("hyperlane:aleo/eth->ethereum/eth")) == IGP_KEY_ETH + assert hl.compute_gas_payment(gas_limit=44000, gas_overhead=159337, gas_price=1000000000, exchange_rate=402) == 8174147 + with pytest.raises(ConfigurationError, match="positive u64"): + hl.compute_gas_payment(gas_limit=0, gas_overhead=0, gas_price=1, exchange_rate=1) + with pytest.raises(ConfigurationError, match="positive u64"): + hl.compute_gas_payment(gas_limit=1, gas_overhead=0, gas_price=2**128 - 1, exchange_rate=2**128 - 1) + + +@pytest.mark.parametrize("asset,route_id,program,recipient,amount", ROUTES) +def test_outbound_route_and_common_shape(bridge, asset, route_id, program, recipient, amount): + route = bridge.hyperlane.outbound_route(asset) + assert route.id == route_id and route.meta_str("aleoRouterProgram") == program + inputs = _inputs(bridge, route_id, recipient, amount, gas=1) + assert len(inputs) == 7 + assert inputs[1] == MAILBOX_STATE + assert inputs[6].count("spender:") == 4 and inputs[6].count("amount: 0u64") == 3 and "amount: 1u64" in inputs[6] + assert inputs[6].startswith("[{ spender: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, amount: 1u64 }, { spender: " + ZERO) + assert inputs[5] == f"{amount}u128" + + +def test_eth_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/eth->ethereum/eth", EVM1, 10**18) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 133188123661477349522757068766864658505569365361420630212878794317749195359field, " + "local_decimals: 18u8, remote_decimals: 18u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, " + "243u8, 11u8, 126u8, 193u8, 250u8, 90u8], gas: 44000u128 }") + assert inputs[3] == "1u32" + assert inputs[4] == "[0u128, 1329227995784915872903807060280344576u128]" + assert inputs[5] == "1000000000000000000u128" + assert inputs[6] == ("[{ spender: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, amount: 8174147u64 }, " + f"{{ spender: {ZERO}, amount: 0u64 }}, {{ spender: {ZERO}, amount: 0u64 }}, {{ spender: {ZERO}, amount: 0u64 }}]") + + +def test_wbtc_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/wbtc->ethereum/wbtc", EVM1, 10**8) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 1505227928464760254508513036497943623956572091841806589002910775534260084309field, " + "local_decimals: 8u8, remote_decimals: 8u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, " + "92u8, 13u8, 49u8, 15u8, 135u8, 114u8], gas: 68000u128 }") + assert inputs[5] == "100000000u128" + + +def test_usdt_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/usdt->ethereum/usdt", EVM1, 10**6) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 8295938150000417034830036849466229528602563851235385582732969109393809606969field, " + "local_decimals: 6u8, remote_decimals: 18u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, " + "240u8, 68u8, 227u8, 63u8, 191u8, 49u8], gas: 68000u128 }") + assert inputs[5] == "1000000u128" + + +def test_sol_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/sol->solana/sol", SOL_SYSTEM, 10**9) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 6148061383892805373029428966764338809222769879628268522058032128225601478383field, " + "local_decimals: 9u8, remote_decimals: 9u8 }") + assert inputs[2] == ("{ domain: 1399811149u32, recipient: [112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, " + "21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, " + "143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8], gas: 300000u128 }") + assert inputs[3] == "1399811149u32" and inputs[4] == "[0u128, 0u128]" and inputs[5] == "1000000000u128" + real = _inputs(bridge, "hyperlane:aleo/sol->solana/sol", "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", 1) + assert real[4] == "[127878782877948140186055645953777992816u128, 163261512394675613100746600600636171918u128]" + + +def test_placeholder_usad_route_is_inspectable_but_not_executable(bridge): + usad = REG.route("hyperlane:aleo/usad->ethereum/usad") + inputs = bridge.hyperlane.build_transfer_remote_inputs(usad, recipient=EVM1, amount_atomic=1_000_000, + gas_payment_microcredits=1, decimals=(6, 6)) + assert inputs[3] == "1u32" and inputs[4] == "[0u128, 1329227995784915872903807060280344576u128]" and inputs[5] == "1000000u128" + assert "gas: 0u128" in inputs[2] and inputs[0].startswith("{ token_type: 0u8, token_owner: aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n") + with pytest.raises(RouteUnavailableError, match="not executable"): + bridge.hyperlane.outbound_route("aleo/usad") + with pytest.raises(RouteUnavailableError): + bridge.hyperlane.transfer_remote("aleo/usad", EVM1, amount="1") + with pytest.raises(RouteUnavailableError): # four metadata-required ALEO routes, none active + bridge.hyperlane.outbound_route("aleo/aleo") + with pytest.raises(UnsupportedRouteError, match="Aleo asset"): + bridge.hyperlane.outbound_route("ethereum/eth") + assert bridge.aleo.calls == [] + + +def test_input_validation(bridge): + route = REG.route("hyperlane:aleo/eth->ethereum/eth") + kw = dict(recipient=EVM1, amount_atomic=1, gas_payment_microcredits=1, decimals=(18, 18)) + for bad in (0, 1 << 64): + with pytest.raises(ConfigurationError, match="positive u64"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "gas_payment_microcredits": bad}) + with pytest.raises(InvalidAmountError, match="greater than zero"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "amount_atomic": 0}) + with pytest.raises(InvalidRecipientError, match="ethereum address format"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "recipient": "aleo1" + "a" * 58}) + with pytest.raises(InvalidRecipientError): + bridge.hyperlane.build_transfer_remote_inputs(REG.route("hyperlane:aleo/sol->solana/sol"), **{**kw, "recipient": EVM1, "decimals": (9, 9)}) + with pytest.raises(InvalidAmountError): + bridge.hyperlane.transfer_remote("aleo/eth", EVM1, amount="0.1234567890123456789") # 19 fractional digits + + +def test_quote_gas_payment_vector(bridge): + quote = bridge.hyperlane.quote_gas_payment("aleo/eth") + assert (quote.route_id, quote.gas_limit, quote.gas_overhead, quote.gas_price, quote.exchange_rate, quote.payment_microcredits) == \ + ("hyperlane:aleo/eth->ethereum/eth", 44000, 159337, 1000000000, 402, 8174147) + assert bridge.aleo.fetched.count("hyp_hook_manager.aleo") >= 1 + sol = bridge.hyperlane.quote_gas_payment("aleo/sol") # SOL_GAS_CONFIG: (300000+200000)*50000000*1000 // 10**10 + assert (sol.gas_limit, sol.payment_microcredits) == (300000, 2_500_000) + + +def test_quote_gas_payment_failure_modes(bridge): + configs = bridge.aleo.mappings["hyp_hook_manager.aleo"]["destination_gas_configs"] + configs[IGP_KEY_ETH] = "{ gas_overhead: 0u128, exchange_rate: 0u128, gas_price: 0u128 }" + with pytest.raises(ConfigurationError, match="unpriced"): + bridge.hyperlane.quote_gas_payment("aleo/eth") + del configs[IGP_KEY_ETH] + with pytest.raises(ConfigurationError, match="missing on chain"): + bridge.hyperlane.quote_gas_payment("aleo/eth") + with pytest.raises(UnsupportedRouteError): + bridge.hyperlane.quote_gas_payment("ethereum/eth") + + +def test_zero_gas_limit_falls_back_to_50000(bridge): + from dataclasses import replace + route = REG.route("hyperlane:aleo/eth->ethereum/eth") + zero = replace(route, metadata={**route.metadata, "aleoRemoteRouterGas": "0"}) + quote = bridge.hyperlane.quote_gas_payment(zero) + assert quote.gas_limit == 50_000 and quote.payment_microcredits == (50_000 + 159337) * 1000000000 * 402 // 10**10 + + +def test_transfer_remote_builds_call_with_live_quote(bridge): + # WBTC shares the Ethereum IGP config (same destination domain 1u32) but carries its own + # aleoRemoteRouterGas (68000, vs. ETH's 44000); per compute_gas_payment the live quote is + # (68000 + 159337) * 1_000_000_000 * 402 // 10_000_000_000 == 9138947, not ETH's 8174147. + call = bridge.hyperlane.transfer_remote("aleo/wbtc", EVM1, amount="0.0001", as_signer=True) + assert (call.program_id, call.function_name) == ("hyp_warp_token_wbtc_v2.aleo", "transfer_remote_as_signer") + assert call.inputs[5] == "10000u128" and "amount: 9138947u64" in call.inputs[6] + assert bridge.aleo.submitted == [] # nothing sent until a verb runs + result = call.delegate(wait=False) + assert isinstance(result, DispatchReceipt) + assert (result.transaction_id, result.route_id, result.message_id, result.amount_atomic) == ("at1delegated", "hyperlane:aleo/wbtc->ethereum/wbtc", None, 10_000) + assert result.receipt.status is Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == "at1delegated" + assert result.receipt.protocol_state == {"routeId": "hyperlane:aleo/wbtc->ethereum/wbtc", "sourceProgram": "hyp_warp_token_wbtc_v2.aleo", + "sourceFunction": "transfer_remote_as_signer", "amountAtomic": "10000", + "recipient": EVM1, "gasPaymentMicrocredits": "9138947"} + assert bridge.aleo.calls[-1] == ("hyp_warp_token_wbtc_v2.aleo", "transfer_remote_as_signer", call.inputs) + + +def test_transfer_remote_pins_explicit_gas_payment(bridge): + call = bridge.hyperlane.transfer_remote("aleo/eth", EVM1, amount_atomic=1, gas_payment_microcredits=123) + assert call.function_name == "transfer_remote" and "amount: 123u64" in call.inputs[6] + assert "hyp_hook_manager.aleo" not in bridge.aleo.fetched # no quote read when pinned + + +def test_is_delivered_reads_mailbox_deliveries(bridge): + assert bridge.hyperlane.is_delivered("0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d") is True + assert bridge.hyperlane.is_delivered(bytes.fromhex("c7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d")) is True + assert bridge.hyperlane.is_delivered("0x" + "00" * 32) is False + with pytest.raises(ConfigurationError, match="32-byte message id"): + bridge.hyperlane.is_delivered("0x1234") diff --git a/bridge-sdk/tests/test_import_without_web3.py b/bridge-sdk/tests/test_import_without_web3.py new file mode 100644 index 0000000..edd3550 --- /dev/null +++ b/bridge-sdk/tests/test_import_without_web3.py @@ -0,0 +1,28 @@ +"""``import aleo_bridge`` must succeed with web3/eth_account absent; only the first Ethereum(...) +call that actually needs them raises MissingExtraError. Uses the same monkeypatch-and-revert +pattern as test_package.py's test_import_without_optional_extras so the blocked modules and the +reimported aleo_bridge never leak into tests that run after this one. +""" +from __future__ import annotations + +import importlib +import sys + +import pytest + + +def test_package_imports_without_web3(monkeypatch): + monkeypatch.setitem(sys.modules, "web3", None) + monkeypatch.setitem(sys.modules, "eth_account", None) + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + monkeypatch.delitem(sys.modules, name) + + pkg = importlib.import_module("aleo_bridge") + assert pkg.Ethereum is not None and pkg.EthModule is not None and pkg.EvmCall is not None + + from aleo_bridge.errors import MissingExtraError + + with pytest.raises(MissingExtraError) as exc_info: + pkg.Ethereum("http://127.0.0.1:1") + assert "aleo-bridge-sdk[evm]" in str(exc_info.value) diff --git a/bridge-sdk/tests/test_keccak.py b/bridge-sdk/tests/test_keccak.py new file mode 100644 index 0000000..0a16a1c --- /dev/null +++ b/bridge-sdk/tests/test_keccak.py @@ -0,0 +1,22 @@ +import pytest + +from aleo_bridge._keccak import keccak256 + + +def test_keccak_known_answers(): + assert keccak256(b"").hex() == "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + assert keccak256(b"abc").hex() == "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45" + # Hyperlane Mailbox event topic — pinned by the whole ecosystem. + assert keccak256(b"DispatchId(bytes32)").hex() == \ + "788dbc1b7152732178210e7f4d9d010ef016f9eafbe66786bd7169f56e0c353a" + + +def test_keccak_accepts_bytearray_and_memoryview(): + assert keccak256(bytearray(b"abc")) == keccak256(b"abc") + assert keccak256(memoryview(b"abc")) == keccak256(b"abc") + + +def test_keccak_long_input_spans_blocks_and_agrees_with_web3(): + web3 = pytest.importorskip("web3") # dev extra; the primitive still ships without it + for data in (bytes(range(256)) * 3, bytes(135), bytes(136), bytes(137), b"x" * 1000): + assert keccak256(data) == bytes(web3.Web3.keccak(data)) diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py new file mode 100644 index 0000000..f71d1b0 --- /dev/null +++ b/bridge-sdk/tests/test_live_helpers.py @@ -0,0 +1,979 @@ +"""Hermetic tests for the live-funds harness itself (port of veil `test/integration/live/helpers.test.ts`). + +Nothing here touches a network, a key, or a chain: the gates are exercised with a monkeypatched +environment, the state files in ``tmp_path``, the Hyperlane explorer through an injected HTTP +callable, and the rehearsal CLI against ``FakeBridge``. The harness is tested BEFORE the funded +cases can run it, which is the whole point of the file: a bug in the gate is a bug that spends +real money. +""" +from __future__ import annotations + +import json +import os +import stat + +import pytest + +from tests.live import config as live_config +from tests.live import helpers as live_helpers + +FUNDS = "BRIDGE_LIVE_FUNDS" +STATE_DIR = "BRIDGE_LIVE_STATE_DIR" +ACK = "BRIDGE_LIVE_MAINNET_ACK" +CASES = "BRIDGE_LIVE_MAINNET_CASES" +EXECUTE = "BRIDGE_LIVE_MAINNET_EXECUTE" + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Every gate variable starts unset: the truth tables below set exactly what they test.""" + for name in (FUNDS, STATE_DIR, ACK, CASES, EXECUTE, "TEST_EVM_KEY"): + monkeypatch.delenv(name, raising=False) + return monkeypatch + + +# ── config.py: reading required values without ever logging them ────────────── + +def test_required_names_the_variable_and_never_the_value(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", " value-with-space ") + assert live_config.required("TEST_EVM_KEY") == "value-with-space" + + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.required("BRIDGE_LIVE_ABSENT") + assert "BRIDGE_LIVE_ABSENT" in str(excinfo.value) + + +def test_required_treats_whitespace_only_as_missing(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", " ") + with pytest.raises(live_config.LiveConfigError): + live_config.required("TEST_EVM_KEY") + + +def test_normalizes_prefixed_and_unprefixed_evm_private_keys(monkeypatch): + """veil helpers.test.ts:14-20 — the 0x prefix is optional, the value is never echoed.""" + key = "ab" * 32 + monkeypatch.setenv("TEST_EVM_KEY", key) + assert live_config.required_evm_private_key("TEST_EVM_KEY") == f"0x{key}" + monkeypatch.setenv("TEST_EVM_KEY", f"0X{key}") + assert live_config.required_evm_private_key("TEST_EVM_KEY") == f"0x{key}" + + +def test_rejects_a_malformed_evm_private_key_without_printing_it(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", "deadbeef") + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.required_evm_private_key("TEST_EVM_KEY") + message = str(excinfo.value) + assert "TEST_EVM_KEY" in message and "32" in message and "deadbeef" not in message + + +# ── config.py: the gate truth table ─────────────────────────────────────────── + +def test_live_funds_needs_both_the_flag_and_a_state_dir(monkeypatch): + assert live_config.live_funds_enabled() is False + monkeypatch.setenv(FUNDS, "1") + assert live_config.live_funds_enabled() is False # no state dir + monkeypatch.setenv(STATE_DIR, "/tmp/bridge-state") + assert live_config.live_funds_enabled() is True + monkeypatch.setenv(FUNDS, "true") + assert live_config.live_funds_enabled() is False # exactly "1", like veil + + +def test_mainnet_case_requires_funding_state_acknowledgement_and_the_named_case(monkeypatch): + """veil helpers.test.ts:56-64.""" + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(STATE_DIR, "/tmp/bridge-state") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-xreserve, aleo-hyperlane") + + assert live_config.mainnet_case_enabled("evm-xreserve") is True + assert live_config.mainnet_case_enabled("aleo-hyperlane") is True + assert live_config.mainnet_case_enabled("solana-hyperlane") is False + + monkeypatch.setenv(ACK, "yes") + assert live_config.mainnet_case_enabled("evm-xreserve") is False + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.delenv(FUNDS) + assert live_config.mainnet_case_enabled("evm-xreserve") is False + + +def test_execution_requires_a_separate_exact_acknowledgement(monkeypatch): + """veil helpers.test.ts:66-71 — nothing in this repo ever SETS this variable.""" + assert live_config.mainnet_execution_enabled() is False + monkeypatch.setenv(EXECUTE, "yes") + assert live_config.mainnet_execution_enabled() is False + monkeypatch.setenv(EXECUTE, "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS") + assert live_config.mainnet_execution_enabled() is True + + +def test_gates_only_read_the_environment(monkeypatch): + """No gate may write, default or repair a variable — a gate that sets its own key is not a gate.""" + before = dict(os.environ) + live_config.live_funds_enabled() + live_config.mainnet_case_enabled("evm-hyperlane") + live_config.mainnet_execution_enabled() + assert dict(os.environ) == before + + +def test_case_names_are_veils_five_mainnet_cases(): + assert live_config.CASE_NAMES == ("evm-hyperlane", "evm-xreserve", "aleo-hyperlane", + "aleo-xreserve", "solana-hyperlane") + + +def test_one_atomic_unit_per_asset_precision(): + """veil helpers.test.ts:73-77.""" + assert live_config.one_atomic_unit(0) == "1" + assert live_config.one_atomic_unit(6) == "0.000001" + assert live_config.one_atomic_unit(9) == "0.000000001" + assert live_config.one_atomic_unit(18) == "0.000000000000000001" + for bad in (-1, 1.5, True): + with pytest.raises(live_config.LiveConfigError): + live_config.one_atomic_unit(bad) + + +def test_live_state_path_is_namespaced_by_environment(monkeypatch, tmp_path): + monkeypatch.setenv(STATE_DIR, str(tmp_path)) + assert live_config.live_state_path("mainnet", "evm-xreserve") == tmp_path / "mainnet" / "evm-xreserve.json" + with pytest.raises(live_config.LiveConfigError): + live_config.live_state_path("devnet", "evm-xreserve") + monkeypatch.delenv(STATE_DIR) + with pytest.raises(live_config.LiveConfigError): + live_config.live_state_path("mainnet", "evm-xreserve") + + +def test_route_and_recipient_overrides_are_optional(monkeypatch): + assert live_config.case_route_override("evm-hyperlane") is None + monkeypatch.setenv("BRIDGE_LIVE_EVM_HYPERLANE_ROUTE_ID", "hyperlane:ethereum/wbtc->aleo/wbtc") + assert live_config.case_route_override("evm-hyperlane") == "hyperlane:ethereum/wbtc->aleo/wbtc" + + assert live_config.recipient_override("aleo") is None + monkeypatch.setenv("BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT", "aleo1recipient") + assert live_config.recipient_override("aleo") == "aleo1recipient" + with pytest.raises(live_config.LiveConfigError): + live_config.recipient_override("bitcoin") + + +# ── helpers.py: state files ─────────────────────────────────────────────────── + +def test_state_round_trips_and_starts_empty_for_an_absent_file(tmp_path): + """veil helpers.test.ts:34-41.""" + path = tmp_path / "mainnet" / "state.json" + assert live_helpers.load_live_state(path, "route:a") == live_helpers.LiveState(route_id="route:a") + + state = live_helpers.LiveState(route_id="route:a", source_tx_id="source-1") + live_helpers.save_live_state(path, state) + assert live_helpers.load_live_state(path, "route:a") == state + + +def test_state_keeps_every_recorded_field_across_a_reload(tmp_path): + path = tmp_path / "state.json" + state = live_helpers.LiveState( + route_id="route:a", source_tx_id="0xsource", message_id="0xmessage", + destination_tx_id="at1destination", destination_balance_before="1000", completed=True, + checkpoint={"version": 1, "receiptId": "r"}, secret_nonce_present=True) + live_helpers.save_live_state(path, state) + assert live_helpers.load_live_state(path, "route:a") == state + assert json.loads(path.read_text())["routeId"] == "route:a" # veil's on-disk key names + + +def test_state_files_are_owner_only_and_written_atomically(tmp_path): + path = tmp_path / "nested" / "state.json" + live_helpers.save_live_state(path, live_helpers.LiveState(route_id="route:a")) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + assert not list(path.parent.glob("*.tmp")) + + +def test_state_fails_closed_for_corrupt_malformed_or_wrong_route_files(tmp_path): + """veil helpers.test.ts:43-52 — a state file we cannot trust never becomes a fresh run.""" + path = tmp_path / "state.json" + + path.write_text("{") + with pytest.raises(live_helpers.LiveStateError): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps([1, 2])) + with pytest.raises(live_helpers.LiveStateError): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:b", "sourceTxId": "x"})) + with pytest.raises(live_helpers.LiveStateError, match="does not match"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "sourceTxId": 1})) + with pytest.raises(live_helpers.LiveStateError, match="sourceTxId"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "completed": "yes"})) + with pytest.raises(live_helpers.LiveStateError, match="completed"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "checkpoint": "not-an-object"})) + with pytest.raises(live_helpers.LiveStateError, match="checkpoint"): + live_helpers.load_live_state(path, "route:a") + + +# ── helpers.py: the secret nonce lives beside the state, never inside it ────── + +def test_secret_nonce_is_a_valid_scalar_and_freshly_random(): + from aleo_bridge.encoding import validate_scalar + + first = live_helpers.generate_secret_nonce() + assert validate_scalar(first) == first + assert first != live_helpers.generate_secret_nonce() + + +def test_secret_file_is_exclusive_owner_only_and_absent_from_the_state(tmp_path): + path = tmp_path / "state.json" + nonce = live_helpers.ensure_secret_nonce(path) + secret = live_helpers.secret_path(path) + + assert secret.name.endswith(".secret") + assert stat.S_IMODE(secret.stat().st_mode) == 0o600 + assert live_helpers.load_secret_nonce(path) == nonce + assert live_helpers.ensure_secret_nonce(path) == nonce # stable across invocations + + with pytest.raises(FileExistsError): + live_helpers.save_secret_nonce(path, nonce) # O_CREAT|O_EXCL: never overwritten + + live_helpers.save_live_state(path, live_helpers.LiveState(route_id="route:a", secret_nonce_present=True)) + assert nonce not in path.read_text() + assert json.loads(path.read_text())["secretNoncePresent"] is True + + +def test_missing_secret_file_reads_as_none(tmp_path): + assert live_helpers.load_secret_nonce(tmp_path / "state.json") is None + + +# ── helpers.py: benchmark marks ─────────────────────────────────────────────── + +def test_benchmark_reports_per_step_and_total_elapsed_time(): + """veil helpers.test.ts:22-32 (millisecond deltas from an injected clock).""" + times = iter([1.000, 1.250, 1.900]) + lines: list[str] = [] + benchmark = live_helpers.LiveBenchmark("route", now=lambda: next(times), log=lines.append) + + benchmark.mark("quote-returned") + benchmark.mark("execute-returned") + + assert lines == ["[route] quote-returned: +250ms (total 250ms)", + "[route] execute-returned: +650ms (total 900ms)"] + assert [m.step for m in benchmark.marks] == ["quote-returned", "execute-returned"] + assert [m.elapsed_ms for m in benchmark.marks] == [250, 650] + assert benchmark.summary() == "route: quote-returned +250ms, execute-returned +650ms (total 900ms)" + + +def test_benchmark_summary_of_a_run_that_marked_nothing(): + benchmark = live_helpers.LiveBenchmark("route", now=lambda: 0.0, log=lambda _: None) + assert benchmark.summary() == "route: no marks" + + +# ── helpers.py: polling ─────────────────────────────────────────────────────── + +def test_wait_for_returns_the_first_non_none_read_and_sleeps_between_polls(): + reads = iter([None, None, "value"]) + slept: list[float] = [] + clock = iter([0.0, 15.0, 30.0, 45.0]) + + value = live_helpers.wait_for(lambda: next(reads), timeout_seconds=600, poll_seconds=15, + sleep=slept.append, now=lambda: next(clock)) + assert value == "value" and slept == [15, 15] + + +def test_wait_for_raises_at_the_deadline_and_names_the_state_file(): + clock = iter([0.0, 5.0, 10.0, 10.0]) + with pytest.raises(live_helpers.LiveTimeoutError, match="state file"): + live_helpers.wait_for(lambda: None, timeout_seconds=10, poll_seconds=1, + sleep=lambda _: None, now=lambda: next(clock)) + + +def test_wait_for_always_reads_at_least_once_even_with_a_zero_timeout(): + calls = [] + + def read(): + calls.append(1) + return "immediate" + + assert live_helpers.wait_for(read, timeout_seconds=0, sleep=lambda _: None) == "immediate" + assert calls == [1] + + +# ── helpers.py: the Hyperlane explorer (read-only HTTP) ─────────────────────── + +class _Response: + def __init__(self, payload, status_code=200): + self._payload, self.status_code = payload, status_code + + def json(self): + return self._payload + + +DELIVERED = {"data": {"message_view": [{"msg_id": "\\xmessage", "is_delivered": True, + "destination_tx_hash": "\\xdestination"}]}} + + +def test_hyperlane_lookup_uses_bytea_hashes_and_normalizes_the_result(): + """veil helpers.test.ts:81-103 — the explorer speaks PostgreSQL bytea, the SDK speaks 0x.""" + seen = {} + + def post(url, payload, timeout): + seen.update(url=url, payload=payload) + return _Response(DELIVERED) + + delivery = live_helpers.hyperlane_delivery("0xsource", post=post) + assert delivery == live_helpers.HyperlaneDelivery(message_id="0xmessage", destination_tx_id="0xdestination") + assert seen["url"] == live_helpers.HYPERLANE_EXPLORER_URL + assert "$hash: bytea!" in seen["payload"]["query"] + assert seen["payload"]["variables"]["hash"] == "\\xsource" + + +def test_hyperlane_lookup_decodes_a_solana_base58_signature(): + """veil helpers.test.ts:105-126.""" + seen = {} + + def post(url, payload, timeout): + seen.update(payload=payload) + return _Response(DELIVERED) + + live_helpers.hyperlane_delivery( + "QrRfJM8xSiKgvqgd8PeiYTgyA7EkLbzKSnEn5wV6amxA4P15cQY41Vh4H85km8RvTX5pDph6oKxhVzsewdGhdnM", post=post) + assert seen["payload"]["variables"]["hash"] == ( + "\\x1491b6d2018d56b09ce9e368e701ccfc618485ff784f6419fe72d660a4a992d5" + "f5d0a4392bf75b8172f57faeea28c3e660c0e9544e4320fb9f4df4d9cce9da06") + + +def test_hyperlane_lookup_is_none_while_the_message_is_undelivered(): + undelivered = {"data": {"message_view": [{"msg_id": "\\xmessage", "is_delivered": False}]}} + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response(undelivered)) is None + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response({"data": {"message_view": []}})) is None + + +@pytest.mark.parametrize("status", [429, 500, 502, 503]) +def test_hyperlane_lookup_returns_none_when_the_explorer_rate_limits_or_fails(status): + """A throttled explorer is an environment condition, never a verdict on the transfer.""" + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response({}, status)) is None + + +def test_hyperlane_lookup_surfaces_graphql_errors(): + """veil helpers.test.ts:128-136 — a bad query must not poll silently until the timeout.""" + errors = {"errors": [{"message": "invalid bytea input"}]} + with pytest.raises(live_helpers.ExplorerError, match="invalid bytea input"): + live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response(errors)) + + +def test_wait_for_hyperlane_delivery_gives_up_quietly_rather_than_failing_a_done_leg(): + """The leg is already `done`; the destination-tx lookup is a convenience, not an assertion.""" + assert live_helpers.wait_for_hyperlane_delivery( + "0xsource", post=lambda *a, **k: _Response({}, 429), + timeout_seconds=0, poll_seconds=0, sleep=lambda _: None) is None + + delivery = live_helpers.wait_for_hyperlane_delivery( + "0xsource", post=lambda *a, **k: _Response(DELIVERED), + timeout_seconds=60, poll_seconds=0, sleep=lambda _: None) + assert delivery.destination_tx_id == "0xdestination" + + +# ── helpers.py: Aleo confirmation ───────────────────────────────────────────── + +class _StatusBridge: + def __init__(self, statuses): + self.statuses = iter(statuses) + self.seen: list[str] = [] + + def status_of(self, tx_id): + self.seen.append(tx_id) + return next(self.statuses) + + +def test_wait_for_aleo_transaction_accepts_after_pending(monkeypatch): + bridge = _StatusBridge([("pending", None), ("accepted", None)]) + monkeypatch.setattr(live_helpers, "aleo_transaction_status", lambda b, tx: b.status_of(tx)) + live_helpers.wait_for_aleo_transaction(bridge, "at1x", timeout_seconds=60, poll_seconds=0, + sleep=lambda _: None) + assert bridge.seen == ["at1x", "at1x"] + + +def test_wait_for_aleo_transaction_raises_on_a_rejected_transaction(monkeypatch): + """veil helpers.test.ts:139-150 — a rejected transaction is a failure, not a slow confirmation.""" + bridge = _StatusBridge([("rejected", "Aleo transaction at1rejected was rejected by the network")]) + monkeypatch.setattr(live_helpers, "aleo_transaction_status", lambda b, tx: b.status_of(tx)) + with pytest.raises(live_helpers.LiveCaseError, match="rejected"): + live_helpers.wait_for_aleo_transaction(bridge, "at1rejected", timeout_seconds=60, poll_seconds=0, + sleep=lambda _: None) + + +def test_underfunded_carries_the_shortfall(): + error = live_helpers.Underfunded(asset_id="ethereum/usdc", needed=2_000_000, have=1_500_000) + assert error.shortfall == 500_000 + assert "ethereum/usdc" in str(error) and "500000" in str(error) + + +# ══ cases.py ══════════════════════════════════════════════════════════════════ + +from pathlib import Path # noqa: E402 + +from aleo_bridge import lifecycle # noqa: E402 +from aleo_bridge.registry import DEFAULT_REGISTRY # noqa: E402 +from aleo_bridge.types import Progress, Receipt, Status # noqa: E402 +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge # noqa: E402 +from tests.live import cases as live_cases # noqa: E402 + +ETH_ROUTE = "hyperlane:ethereum/eth->aleo/eth" +USDC_ROUTE = "xreserve:ethereum/usdc->aleo/usdcx" + + +class LiveFakeBridge(FakeBridge): + """``FakeBridge`` plus the public lifecycle verbs — the surface ``cases.py`` is allowed to use. + + The real ``Bridge`` methods are thin wrappers over ``lifecycle``; wiring the same functions onto + the fake keeps the harness honest (it may only call verbs that exist) without a second fake. + """ + + def quote(self, source, destination, **kwargs): + return lifecycle.quote(self, source=source, destination=destination, **kwargs) + + def execute(self, plan, **kwargs): + return lifecycle.execute(self, plan, **kwargs) + + def wait(self, progress, **kwargs): + return lifecycle.wait(self, progress, **kwargs) + + def recover(self, checkpoint): + return lifecycle.recover(self, checkpoint) + + def resume(self, progress, **kwargs): + return lifecycle.resume(self, progress, **kwargs) + + def complete(self, progress, **kwargs): + return lifecycle.complete(self, progress, **kwargs) + + def pending(self): + if self.checkpoints is None: + return [] + return [lifecycle.progress_from_checkpoint(self.registry, cp) for cp in self.checkpoints.list()] + + +@pytest.fixture +def fake(): + return LiveFakeBridge() + + +def test_cases_are_veils_five_mainnet_cases_with_their_literals(): + assert live_cases.CASE_NAMES == live_config.CASE_NAMES + assert live_cases.CASES["evm-xreserve"].amount == "2" # veil evm-xreserve:57 + assert live_cases.CASES["evm-xreserve"].mint_mode == "private" # veil evm-xreserve:60 + assert live_cases.CASES["aleo-xreserve"].amount == "2.000001" # veil aleo-xreserve:97 + assert live_cases.CASES["aleo-xreserve"].mode == "private" + assert live_cases.CASES["aleo-hyperlane"].mode == "signer" # veil aleo-hyperlane:135 + assert live_cases.CASES["evm-hyperlane"].amount is None # one atomic unit + assert all(spec.veil_source for spec in live_cases.CASES.values()) + + +def test_every_mainnet_route_is_covered_by_exactly_one_case(): + """'All the routes back and forth': no mainnet route may be left without a case.""" + routes = DEFAULT_REGISTRY.routes(environment="mainnet") + covered = {route.id: live_cases.case_for_route(DEFAULT_REGISTRY, route) for route in routes} + assert all(case is not None for case in covered.values()), \ + [rid for rid, case in covered.items() if case is None] + + by_case = {case: {route.id for route in live_cases.routes_for_case(DEFAULT_REGISTRY, case)} + for case in live_cases.CASE_NAMES} + assert set().union(*by_case.values()) == set(covered) + for left in live_cases.CASE_NAMES: + for right in live_cases.CASE_NAMES: + if left != right: + assert not by_case[left] & by_case[right] + + active = {route.id for route in routes if route.active} + assert ETH_ROUTE in by_case["evm-hyperlane"] and "hyperlane:ethereum/wbtc->aleo/wbtc" in by_case["evm-hyperlane"] + assert "hyperlane:aleo/sol->solana/sol" in by_case["aleo-hyperlane"] + assert by_case["solana-hyperlane"] & active == {"hyperlane:solana/sol->aleo/sol"} + assert by_case["evm-xreserve"] & active == {USDC_ROUTE} + assert by_case["aleo-xreserve"] & active == {"xreserve:aleo/usdcx->ethereum/usdc"} + + +def test_default_amount_is_one_atomic_unit_or_veils_literal(): + route = DEFAULT_REGISTRY.route(ETH_ROUTE) + assert live_cases.default_amount(DEFAULT_REGISTRY, "evm-hyperlane", route) == "0.000000000000000001" + usdc = DEFAULT_REGISTRY.route(USDC_ROUTE) + assert live_cases.default_amount(DEFAULT_REGISTRY, "evm-xreserve", usdc) == "2" + + +def test_state_names_are_route_qualified(): + assert live_cases.state_name("evm-hyperlane", ETH_ROUTE) == "evm-hyperlane-hyperlane-ethereum-eth-aleo-eth" + assert live_cases.state_name("evm-hyperlane", "hyperlane:ethereum/wbtc->aleo/wbtc") \ + != live_cases.state_name("evm-hyperlane", ETH_ROUTE) + + +def test_quote_only_prints_the_table_and_submits_nothing(fake, tmp_path): + lines: list[str] = [] + state_path = tmp_path / "evm-hyperlane.json" + state = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, + execute=False, log=lines.append) + + assert state.completed is False and state.checkpoint is None + assert not state_path.exists() # a rehearsal leaves no state behind + assert not any(event[0] in {"evm_send", "submit", "prove"} for event in fake.events) + printed = "\n".join(lines) + assert ETH_ROUTE in printed and "evm-hyperlane" in printed and "quote only" in printed + + +def test_the_case_records_veils_benchmark_marks(fake, tmp_path): + benchmark = live_helpers.LiveBenchmark("evm-hyperlane", log=lambda _: None) + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=tmp_path / "s.json", + execute=False, benchmark=benchmark, log=lambda _: None) + assert [mark.step for mark in benchmark.marks] == ["plan-prepared", "quote-returned"] + + +def test_quote_only_never_creates_the_private_mint_secret(fake, tmp_path): + state_path = tmp_path / "evm-xreserve.json" + live_cases.run_case(fake, "evm-xreserve", USDC_ROUTE, state_path=state_path, execute=False, + log=lambda _: None) + assert not live_helpers.secret_path(state_path).exists() + + +def test_underfunded_names_the_asset_and_the_shortfall(fake, tmp_path): + fake.eth.balances = {"ethereum/eth": 5} + with pytest.raises(live_helpers.Underfunded) as excinfo: + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=tmp_path / "s.json", + execute=False, log=lambda _: None) + assert excinfo.value.asset_id == "ethereum/eth" and excinfo.value.shortfall > 0 + + +def test_a_completed_case_is_a_no_op_that_re_asserts_its_record(fake, tmp_path): + state_path = tmp_path / "done.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState( + route_id=ETH_ROUTE, source_tx_id="0xsource", message_id="0xmessage", + destination_tx_id="at1destination", completed=True)) + + state = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, + execute=True, log=lambda _: None) + assert state.completed and state.source_tx_id == "0xsource" + assert fake.calls == [] and fake.events == [] # nothing was quoted, nothing was submitted + + +def test_a_completed_state_without_a_source_transaction_fails_closed(fake, tmp_path): + state_path = tmp_path / "bad.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState(route_id=ETH_ROUTE, completed=True)) + with pytest.raises(live_helpers.LiveCaseError): + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + log=lambda _: None) + + +def test_an_inactive_route_is_refused_before_anything_is_read(fake, tmp_path): + with pytest.raises(live_helpers.LiveCaseError, match="metadata-required"): + live_cases.run_case(fake, "evm-hyperlane", "hyperlane:ethereum/usad->aleo/usad", + state_path=tmp_path / "s.json", execute=False, log=lambda _: None) + + +def test_recipient_and_sender_default_to_our_own_addresses(fake): + route = DEFAULT_REGISTRY.route(ETH_ROUTE) + assert live_cases.default_recipient(fake, route) == ALEO_RECIPIENT + assert live_cases.sender_for(fake, route) == EVM_ADDRESS + + outbound = DEFAULT_REGISTRY.route("hyperlane:aleo/eth->ethereum/eth") + assert live_cases.default_recipient(fake, outbound) == EVM_ADDRESS + assert live_cases.sender_for(fake, outbound) == ALEO_RECIPIENT + + +def test_an_unconfigured_destination_chain_is_reported_not_an_attribute_error(): + """Ruling: probe ``bridge.solana``/``bridge.ethereum``; ``bridge.sol``/``bridge.eth`` RAISE.""" + aleo_only = LiveFakeBridge(ethereum=False, solana=False) + assert aleo_only.ethereum is None and aleo_only.solana is None + route = DEFAULT_REGISTRY.route("hyperlane:aleo/sol->solana/sol") + with pytest.raises(live_helpers.LiveCaseError, match="solana"): + live_cases.default_recipient(aleo_only, route) + aleo_only.public_balances = {"aleo/sol": 7} + assert live_cases.read_balances(aleo_only) == {"aleo/sol": 7} # the Aleo row still reads + + +def _xreserve_quote(**extra): + from aleo_bridge.types import EvmXReserveQuote + + plan = lifecycle.prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="2", recipient=ALEO_RECIPIENT, mint_mode="private") + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), amount_out="2", hook_data=b"", + remote_recipient_bytes32=b"\x00" * 32, balance_atomic=5_000_000, + allowance_atomic=0, approval_required=True, **extra) + + +def test_print_quote_renders_the_xreserve_max_fee_as_the_protocol_fee_line(fake): + """``EvmXReserveQuote.fees`` is empty: its max fee is the protocol cost a human has to see.""" + lines: list[str] = [] + live_cases.print_quote(fake, _xreserve_quote(max_fee_atomic=100_000), + case="evm-xreserve", route_id=USDC_ROUTE, log=lines.append) + printed = "\n".join(lines) + assert "0.1 USDC [xReserve max fee]" in printed + assert "max_fee_atomic" in printed and ALEO_RECIPIENT in printed + assert "scalar" not in printed + + +# ── the drive loop: wait → resume → complete → done, and never execute twice ── + +class _ScriptedBridge: + """A bridge whose lifecycle verbs return a scripted sequence, to test ``_drive`` in isolation.""" + + def __init__(self, steps): + self.steps, self.calls = list(steps), [] + self.plan = lifecycle.prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="2", recipient=ALEO_RECIPIENT, mint_mode="private") + + def _next(self, verb): + self.calls.append(verb) + state = self.steps.pop(0) + receipt = Receipt(id="r1", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="0xsource", + protocol_state={"routeId": USDC_ROUTE, "messageId": "0xmessage"}) + return Progress(state, self.plan, receipt, error="scripted failure" if state == "failed" else None) + + def wait(self, progress, **kwargs): + return self._next("wait") + + def resume(self, progress, **kwargs): + return self._next("resume") + + def complete(self, progress, **kwargs): + return self._next("complete") + + def execute(self, *args, **kwargs): + raise AssertionError("execute must never be called from the drive loop") + + +def _drive(bridge, first, **kwargs): + benchmark = live_helpers.LiveBenchmark("t", log=lambda _: None) + progress = Progress(first, bridge.plan, + Receipt(id="r1", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": USDC_ROUTE})) + return live_cases._drive(bridge, progress, live_helpers.LiveState(route_id=USDC_ROUTE), + Path("/nonexistent/state.json"), spec=live_cases.CASES["evm-xreserve"], + benchmark=benchmark, save=lambda _: None, wait_timeout_seconds=1, + wait_poll_seconds=0, log=lambda _: None, **kwargs) + + +def test_drive_runs_wait_resume_complete_until_done(): + bridge = _ScriptedBridge(["resume", "wait", "complete", "wait", "done"]) + progress = _drive(bridge, "wait", secret_nonce="7scalar") + assert progress.next == "done" + assert bridge.calls == ["wait", "resume", "wait", "complete", "wait"] + + +def test_drive_raises_the_reported_error_on_failure(): + bridge = _ScriptedBridge(["failed"]) + with pytest.raises(live_helpers.LiveCaseError, match="scripted failure"): + _drive(bridge, "wait", secret_nonce="7scalar") + + +def test_drive_refuses_a_private_mint_without_the_kept_nonce(): + bridge = _ScriptedBridge(["complete"]) + with pytest.raises(live_helpers.LiveCaseError, match="secret nonce"): + _drive(bridge, "wait", secret_nonce=None) + + +def test_drive_gives_up_rather_than_looping_forever(): + bridge = _ScriptedBridge(["wait"] * 40) + with pytest.raises(live_helpers.LiveCaseError, match="did not settle"): + _drive(bridge, "wait", secret_nonce="7scalar") + + +# ══ scripts/rehearse.py ═══════════════════════════════════════════════════════ + +import importlib.util # noqa: E402 + +from aleo_bridge.errors import PollingTimeoutError # noqa: E402 + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "rehearse.py" + + +@pytest.fixture(scope="module") +def rehearse(): + spec = importlib.util.spec_from_file_location("rehearse", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def state_dir(monkeypatch, tmp_path): + monkeypatch.setenv(STATE_DIR, str(tmp_path)) + return tmp_path + + +def _report(path): + return json.loads(Path(path).read_text()) + + +def test_cli_parses_the_documented_flags(rehearse): + args = rehearse.parse_args(["--case", "evm-hyperlane"]) + assert args.case == "evm-hyperlane" and args.route is None + assert args.quote_only is False and args.recover is None and args.report is None + + args = rehearse.parse_args(["--case", "aleo-xreserve", "--route", "xreserve:aleo/usdcx->ethereum/usdc", + "--quote-only", "--report", "/tmp/r.json"]) + assert args.route == "xreserve:aleo/usdcx->ethereum/usdc" and args.quote_only and args.report == "/tmp/r.json" + + args = rehearse.parse_args(["--recover", "/tmp/state.json"]) + assert args.recover == "/tmp/state.json" and args.case is None + + for bad in ([], ["--case", "not-a-case"]): + with pytest.raises(SystemExit): + rehearse.parse_args(bad) + + +def test_cli_has_no_reset_flag(rehearse): + """veil's solana-deposit.ts --reset deletes a checkpoint without checking the chain; not ported.""" + with pytest.raises(SystemExit): + rehearse.parse_args(["--case", "evm-hyperlane", "--reset"]) + + +def test_quote_only_run_reports_every_route_of_the_case(rehearse, fake, state_dir, tmp_path): + lines: list[str] = [] + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + + assert code == rehearse.EXIT_OK + payload = _report(report) + rows = {row["route_id"]: row for row in payload["results"]} + assert set(rows) == {route.id for route in live_cases.routes_for_case(DEFAULT_REGISTRY, "evm-hyperlane")} + assert rows[ETH_ROUTE]["status"] == "quote-only" + assert rows["hyperlane:ethereum/usad->aleo/usad"]["status"] == "skipped" + assert "metadata-required" in rows["hyperlane:ethereum/usad->aleo/usad"]["reason"] + assert payload["execute"] is False and payload["case"] == "evm-hyperlane" + + printed = "\n".join(lines) + assert ETH_ROUTE in printed and "quote-only" in printed + assert not any(event[0] in {"evm_send", "submit"} for event in fake.events) + + +def test_a_single_route_can_be_selected(rehearse, fake, state_dir, tmp_path): + report = tmp_path / "report.json" + rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + assert [row["route_id"] for row in _report(report)["results"]] == [ETH_ROUTE] + + +def test_without_the_acknowledgements_the_run_quotes_and_names_the_variable(rehearse, fake, state_dir, + monkeypatch, tmp_path): + """The gate is READ here; nothing prints a line that would set it in a subshell.""" + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-hyperlane") # the case is acknowledged, submission is not + lines: list[str] = [] + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + + payload = _report(report) + assert code == rehearse.EXIT_OK and payload["execute"] is False + assert "BRIDGE_LIVE_MAINNET_EXECUTE" in payload["reason"] + printed = "\n".join(lines) + assert "export " not in printed and "I_ACKNOWLEDGE" not in printed + assert "BRIDGE_LIVE_MAINNET_EXECUTE" in printed + + +def test_acknowledged_runs_ask_the_case_to_execute(rehearse, fake, state_dir, monkeypatch, tmp_path): + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-hyperlane") + monkeypatch.setenv(EXECUTE, "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS") + seen = {} + + def runner(bridge, route_id, **kwargs): + seen.update(route_id=route_id, execute=kwargs["execute"], state_path=kwargs["state_path"]) + return live_helpers.LiveState(route_id=route_id, source_tx_id="0xsource", message_id="0xm", + destination_tx_id="at1d", completed=True) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + + assert code == rehearse.EXIT_OK and seen["execute"] is True + assert Path(seen["state_path"]) == state_dir / "mainnet" / f"{live_cases.state_name('evm-hyperlane', ETH_ROUTE)}.json" + row = _report(report)["results"][0] + assert row["status"] == "completed" and row["source_tx_id"] == "0xsource" and row["message_id"] == "0xm" + + +def test_an_underfunded_case_is_skipped_with_its_shortfall(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise live_helpers.Underfunded(asset_id="ethereum/eth", needed=1000, have=1) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_OK and row["status"] == "skipped" and "999" in row["reason"] + + +def test_a_timeout_is_pending_with_the_resume_command_not_a_failure(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise PollingTimeoutError("still in flight", status=Status.DELIVERY_PENDING) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + lines: list[str] = [] + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_PENDING and row["status"] == "pending" + assert "--recover" in row["resume"] and row["resume"] in "\n".join(lines) + + +def test_a_failed_case_exits_one(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise live_helpers.LiveCaseError("the destination rejected it") + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_FAILED and row["status"] == "failed" and "rejected" in row["reason"] + + +def test_recover_resolves_the_case_from_the_state_file(rehearse, fake, state_dir, monkeypatch, tmp_path): + state_path = state_dir / "mainnet" / "evm-hyperlane-resume.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState(route_id=ETH_ROUTE, source_tx_id="0xs")) + seen = {} + + def runner(bridge, route_id, **kwargs): + seen.update(route_id=route_id, state_path=kwargs["state_path"]) + return live_helpers.LiveState(route_id=route_id, source_tx_id="0xs", completed=True) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + code = rehearse.run(["--recover", str(state_path), "--quote-only"], + bridge_factory=lambda: fake, log=lambda _: None) + assert code == rehearse.EXIT_OK + assert seen["route_id"] == ETH_ROUTE and Path(seen["state_path"]) == state_path + + +def test_the_table_renders_one_line_per_route(rehearse): + rows = [{"case": "evm-hyperlane", "route_id": ETH_ROUTE, "status": "quote-only", "reason": "", + "source_tx_id": None, "message_id": None, "destination_tx_id": None, "resume": ""}, + {"case": "evm-hyperlane", "route_id": "hyperlane:ethereum/wbtc->aleo/wbtc", "status": "skipped", + "reason": "registry availability: metadata-required", "source_tx_id": None, + "message_id": None, "destination_tx_id": None, "resume": ""}] + table = rehearse.render_table(rows) + assert ETH_ROUTE in table and "quote-only" in table and "metadata-required" in table + assert len(table.strip().splitlines()) >= 3 # header + two rows + + +# ══ 13b: environment aliases, the execute handover, the suite's parametrization ══ + +def test_key_and_rpc_variables_resolve_per_environment(monkeypatch): + """A testnet run may never reach for the mainnet Aleo key, and an unset RPC has a default.""" + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpMainnet") + monkeypatch.setenv("ALEO_E2E_PRIVATE_KEY", "APrivateKey1zkpTestnet") + assert live_config.aleo_private_key("mainnet") == "APrivateKey1zkpMainnet" + assert live_config.aleo_private_key("testnet") == "APrivateKey1zkpTestnet" + + monkeypatch.setenv("BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY", "APrivateKey1zkpAlias") + assert live_config.aleo_private_key("testnet") == "APrivateKey1zkpAlias" # the alias wins + assert live_config.aleo_private_key("mainnet") == "APrivateKey1zkpMainnet" + + monkeypatch.delenv("BRIDGE_PRIVATE_KEY") + with pytest.raises(live_config.LiveConfigError, match="BRIDGE_PRIVATE_KEY"): + live_config.aleo_private_key("mainnet") + with pytest.raises(live_config.LiveConfigError, match="environment"): + live_config.aleo_private_key("devnet") + + +def test_evm_key_is_normalised_and_the_value_never_appears(monkeypatch): + monkeypatch.setenv("BRIDGE_EVM_PRIVATE_KEY", "0x" + "AB" * 32) + assert live_config.evm_private_key("mainnet") == "0x" + "ab" * 32 + assert live_config.evm_private_key("testnet") == "0x" + "ab" * 32 + + monkeypatch.setenv("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "0x" + "cd" * 32) + assert live_config.evm_private_key("testnet") == "0x" + "cd" * 32 + assert live_config.evm_private_key("mainnet") == "0x" + "ab" * 32 + + monkeypatch.setenv("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "not-a-key") + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.evm_private_key("testnet") + assert "not-a-key" not in str(excinfo.value) + + +def test_rpc_urls_fall_back_to_the_public_defaults(monkeypatch): + for name in ("SEPOLIA_RPC_URL", "BRIDGE_LIVE_SEPOLIA_RPC_URL", "ETHEREUM_RPC_URL", + "BRIDGE_LIVE_ETHEREUM_RPC_URL", "ALEO_ENDPOINT", "BRIDGE_LIVE_ALEO_ENDPOINT"): + monkeypatch.delenv(name, raising=False) + assert live_config.evm_rpc_url("mainnet") == live_config.DEFAULT_ETHEREUM_RPC_URL + assert live_config.evm_rpc_url("testnet") == live_config.DEFAULT_SEPOLIA_RPC_URL + assert live_config.aleo_endpoint() == live_config.DEFAULT_ALEO_ENDPOINT + + monkeypatch.setenv("SEPOLIA_RPC_URL", "https://sepolia.example") + monkeypatch.setenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", "https://eth.example") + monkeypatch.setenv("ALEO_ENDPOINT", "https://aleo.example/api") + assert live_config.evm_rpc_url("testnet") == "https://sepolia.example" + assert live_config.evm_rpc_url("mainnet") == "https://eth.example" + assert live_config.aleo_endpoint() == "https://aleo.example/api" + assert live_config.first_value(("ABSENT_A", "SEPOLIA_RPC_URL")) == ("SEPOLIA_RPC_URL", "https://sepolia.example") + assert live_config.first_value(("ABSENT_A", "ABSENT_B")) is None + + +def test_amount_overrides_are_per_case_with_an_xreserve_shorthand(monkeypatch): + monkeypatch.delenv("BRIDGE_LIVE_XRESERVE_AMOUNT", raising=False) + assert live_config.case_amount_override("evm-xreserve") is None + monkeypatch.setenv("BRIDGE_LIVE_XRESERVE_AMOUNT", "3") + assert live_config.case_amount_override("evm-xreserve") == "3" + assert live_config.case_amount_override("aleo-xreserve") == "3" + assert live_config.case_amount_override("evm-hyperlane") is None # never a Hyperlane amount + monkeypatch.setenv("BRIDGE_LIVE_EVM_XRESERVE_AMOUNT", "5") + assert live_config.case_amount_override("evm-xreserve") == "5" + + +def _refuse_execute(*args, **kwargs): + raise AssertionError("execute must never be called for a transfer that already has a checkpoint") + + +def test_stop_after_execute_hands_the_transfer_over_through_the_state_file(fake, tmp_path): + """Phase one executes and returns; phase two must reach done without executing again.""" + state_path = tmp_path / "handover.json" + first = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + stop_after_execute=True, wait_timeout_seconds=1, wait_poll_seconds=0, + log=lambda _: None) + assert first.checkpoint is not None and first.completed is False and first.source_tx_id + assert state_path.exists() + + submitted = [event for event in fake.events if event[0] in {"evm_send", "submit"}] + fake.execute = _refuse_execute # the handover may only use the recovery verbs + fake.eth.recover_result = Receipt( + id=first.checkpoint["receiptId"], protocol="hyperlane", status=Status.COMPLETED, + source_tx_id=first.source_tx_id, destination_tx_id="at1delivered", + protocol_state={"routeId": ETH_ROUTE, "messageId": "0x" + "ee" * 32}) + second = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + wait_timeout_seconds=1, wait_poll_seconds=0, log=lambda _: None) + assert second.completed and second.source_tx_id == first.source_tx_id + assert [event for event in fake.events if event[0] in {"evm_send", "submit"}] == submitted + + +def test_the_suite_parametrizes_every_route_in_both_environments(): + from tests.live import test_lifecycle_live as suite + + for environment, buckets in (("mainnet", suite.MAINNET_ROUTES), ("testnet", suite.TESTNET_ROUTES)): + listed = {route.id for routes in buckets.values() for route in routes} + assert listed == {route.id for route in DEFAULT_REGISTRY.routes(environment=environment)} + + mainnet = {route.id: route for routes in suite.MAINNET_ROUTES.values() for route in routes} + assert "hyperlane:ethereum/usad->aleo/usad" in mainnet # metadata-required, parametrized + assert not mainnet["hyperlane:ethereum/usad->aleo/usad"].active + assert suite.TESTNET_DEPOSIT_ROUTE in {r.id for r in suite.TESTNET_ROUTES["evm-xreserve"]} + assert suite.TESTNET_RETURN_ROUTE in {r.id for r in suite.TESTNET_ROUTES["aleo-xreserve"]} + assert suite.TESTNET_DEPOSIT_AMOUNT == "3" and suite.TESTNET_RETURN_AMOUNT == "2.000001" + assert "--recover" in suite.resume_command("/tmp/state.json") + + +def test_only_the_aleo_to_evm_withdrawal_measures_delivery_by_balance(): + """The one leg with no delivery query anywhere: `wait` on it could only ever time out.""" + rise = {route.id for route in DEFAULT_REGISTRY.routes() + if live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY)} + assert rise == {"xreserve:aleo/usdcx->ethereum/usdc", "xreserve:aleo-testnet/usdcx->sepolia/usdc"} + assert not live_cases.delivery_is_a_balance_rise(DEFAULT_REGISTRY.route(USDC_ROUTE), DEFAULT_REGISTRY) + assert not live_cases.delivery_is_a_balance_rise(DEFAULT_REGISTRY.route(ETH_ROUTE), DEFAULT_REGISTRY) + + +def test_the_suite_sets_no_acknowledgement_and_prints_no_settable_form(): + source = (SCRIPT.parent.parent / "tests" / "live" / "test_lifecycle_live.py").read_text(encoding="utf-8") + assert "export " not in source + assert live_config.MAINNET_ACK not in source and live_config.MAINNET_EXECUTE_ACK not in source + assert "setenv" not in source and "os.environ[" not in source diff --git a/bridge-sdk/tests/test_mcp.py b/bridge-sdk/tests/test_mcp.py new file mode 100644 index 0000000..0b52fcf --- /dev/null +++ b/bridge-sdk/tests/test_mcp.py @@ -0,0 +1,84 @@ +"""MCP stdio server over the agent tools — schema fidelity, dispatch, and the confirm gate. + +Every test here exercises the module's functions directly (``tool_definitions`` / ``call_tool`` / +the handlers ``build_server`` registers) against a :class:`FakeBridge`. None of them start a real +stdio server: that would require a live client on the other end of the pipe, which is exactly what +the low-level ``mcp.server.Server`` API lets us skip in tests. +""" +import json +import sys + +import pytest + +mcp = pytest.importorskip("mcp") + +from mcp import types # noqa: E402 + +from aleo_bridge.agent import bridge_tools # noqa: E402 +from aleo_bridge.errors import MissingExtraError # noqa: E402 +from aleo_bridge.mcp import build_server, call_tool, tool_definitions # noqa: E402 +from tests.fakes.fake_bridge import ALEO_RECIPIENT, FakeBridge # noqa: E402 + + +def test_tool_definitions_carry_exact_schemas(): + defs = {t.name: t for t in tool_definitions()} + expected = {t["name"]: t for t in bridge_tools()} + assert set(defs) == set(expected) + assert defs["bridge_execute"].inputSchema == expected["bridge_execute"]["input_schema"] + assert "confirm" in defs["bridge_execute"].inputSchema["properties"] + for name, tool in defs.items(): + assert tool.description == expected[name]["description"] + + +def test_build_server_constructs(): + assert build_server(FakeBridge()).name == "aleo-bridge" + + +async def test_call_tool_dispatches_and_serializes(): + b = FakeBridge() + out = await call_tool(b, "bridge_quote", {"source": "ethereum/usdc", "destination": "aleo/usdcx", + "amount": "2", "recipient": ALEO_RECIPIENT}) + assert out[0].type == "text" + assert json.loads(out[0].text)["kind"] == "evm-xreserve" + gated = await call_tool(b, "bridge_execute", {"source": "ethereum/usdc", "destination": "aleo/usdcx", + "amount": "2", "recipient": ALEO_RECIPIENT}) + assert json.loads(gated[0].text)["confirmation_required"] is True and b.events == [] + + +async def test_build_server_handlers_list_and_dispatch_through_the_real_server_wiring(): + """Reach the exact coroutines ``@server.list_tools()``/``@server.call_tool()`` registered — + not just the module-level helpers they delegate to — and confirm the write gate still holds + when a tool call is routed through the server object.""" + b = FakeBridge() + server = build_server(b) + + list_result = await server.request_handlers[types.ListToolsRequest](types.ListToolsRequest()) + names = {t.name for t in list_result.root.tools} + assert names == {t["name"] for t in bridge_tools()} + + call_result = await server.request_handlers[types.CallToolRequest]( + types.CallToolRequest(params=types.CallToolRequestParams( + name="bridge_execute", + arguments={"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", + "recipient": ALEO_RECIPIENT}))) + payload = json.loads(call_result.root.content[0].text) + assert payload["confirmation_required"] is True + assert b.events == [] + + +def test_missing_extra_error_without_mcp_installed(monkeypatch): + """``tool_definitions``/``build_server`` import ``mcp`` lazily: without the extra installed, + calling them fails with the SDK's own ``MissingExtraError`` (naming the real ``mcp`` extra from + ``pyproject.toml``), never a bare ``ImportError`` from deep inside the module.""" + for mod in ("mcp", "mcp.types", "mcp.server", "mcp.server.stdio"): + monkeypatch.setitem(sys.modules, mod, None) + + from aleo_bridge import mcp as bridge_mcp + + with pytest.raises(MissingExtraError) as exc_info: + bridge_mcp.tool_definitions() + assert exc_info.value.extra == "mcp" + assert "aleo-bridge-sdk[mcp]" in str(exc_info.value) + + with pytest.raises(MissingExtraError): + bridge_mcp.build_server(FakeBridge()) diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py new file mode 100644 index 0000000..9fc7157 --- /dev/null +++ b/bridge-sdk/tests/test_package.py @@ -0,0 +1,85 @@ +import importlib +import re +import sys +import tomllib +from pathlib import Path + +import pytest + +import aleo_bridge + +ROOT = Path(__file__).resolve().parents[1] + + +def test_version_is_pinned_in_lockstep(): + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert pyproject["project"]["version"] == "0.1.0" == aleo_bridge.__version__ + assert pyproject["project"]["name"] == "aleo-bridge-sdk" + extras = pyproject["project"]["optional-dependencies"] + assert {"evm", "solana", "mcp", "dev"} <= set(extras) + assert any(dep.startswith("mcp>=1") and "<2" in dep for dep in extras["mcp"]) + + +def test_wheel_ships_agents_md(): + assert (ROOT / "python" / "aleo_bridge" / "AGENTS.md").exists() + assert aleo_bridge.agent_guide().startswith("# aleo-bridge") + + +def test_readme_covers_the_journey(): + readme = (ROOT / "README.md").read_text() + for needle in ("Bridge.from_env()", "Bridge.from_profile()", "Ethereum(", "Solana(", "progress.next", + "recover", "resume", "complete", "shield", "unshield", "python -m aleo_bridge.mcp", + "scripts/rehearse.py", "BRIDGE_PRIVATE_KEY", "EVM_PRIVATE_KEY", "SOLANA_PRIVATE_KEY", + "BRIDGE_LIVE_MAINNET_EXECUTE", "secret_nonce", "| `resume` |", "| `complete` |"): + assert needle in readme, needle + assert "Co-Authored-By" not in readme + # every active mainnet route appears in the route table + for route_id in ("xreserve:ethereum/usdc->aleo/usdcx", "hyperlane:ethereum/eth->aleo/eth", + "hyperlane:aleo/sol->solana/sol"): + assert route_id in readme + + +def test_ci_has_bridge_jobs(): + workflow = (ROOT.parent / ".github" / "workflows" / "sdk-wheels.yml").read_text() + assert workflow.count("'bridge-sdk/**'") == 2 + for job in ("build-bridge:", "release-bridge:"): + assert job in workflow + assert "environment: pypi-bridge" in workflow + assert 'pip install "$(ls bridge-sdk/dist/*.whl)[evm,solana,mcp]"' in workflow + assert re.search(r'pip install "\$\(ls bridge-sdk/dist/\*\.whl\)"\s*\n\s*python -c "import aleo_bridge', workflow) + + +def test_import_without_optional_extras(monkeypatch): + for mod in ("web3", "eth_account", "solders", "solana", "mcp"): + monkeypatch.setitem(sys.modules, mod, None) # any import of these now raises ImportError + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + 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.1.0" + assert issubclass(pkg.RouteNotFoundError, pkg.BridgeError) + + +def test_error_hierarchy_and_messages(): + from aleo_bridge import errors as e + + for cls in (e.ConfigurationError, e.MissingExtraError, e.RouteNotFoundError, e.AmbiguousRouteError, + e.RouteUnavailableError, e.RegistryVersionMismatchError, e.UnsupportedRouteError, + e.InvalidAmountError, e.InvalidRecipientError, e.InsufficientBalanceError, + e.ChainMismatchError, e.NotResumableError, e.CheckpointInvalidError, e.AttestationError, + e.DeliveryUnknownError, e.PollingTimeoutError): + assert issubclass(cls, e.BridgeError) + err = e.MissingExtraError("evm", "Ethereum connections") + assert err.extra == "evm" + assert "pip install 'aleo-bridge-sdk[evm]'" in str(err) + + +def test_polling_timeout_carries_status(): + from aleo_bridge.errors import PollingTimeoutError + + err = PollingTimeoutError("Bridge status polling timed out in state DELIVERY_PENDING", + status="DELIVERY_PENDING", progress=None) + assert err.status == "DELIVERY_PENDING" and err.progress is None + with pytest.raises(PollingTimeoutError): + raise err diff --git a/bridge-sdk/tests/test_prepare.py b/bridge-sdk/tests/test_prepare.py new file mode 100644 index 0000000..9888f1b --- /dev/null +++ b/bridge-sdk/tests/test_prepare.py @@ -0,0 +1,201 @@ +import pytest + +from aleo_bridge._plan import build_plan +from aleo_bridge.errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, + InvalidRecipientError, RouteNotFoundError, + RegistryVersionMismatchError, CheckpointInvalidError) +from aleo_bridge.lifecycle import prepare, resolve_route +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Plan + +ALEO = "aleo1" + "a" * 58 +EVM1 = "0x0000000000000000000000000000000000000001" +SOLANA1 = "11111111111111111111111111111111" + + +def test_resolves_route_from_asset_refs(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO) + assert plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" + assert plan.protocol == "xreserve" and plan.environment == "mainnet" + assert plan.registry_version == DEFAULT_REGISTRY.version + assert (plan.amount, plan.amount_atomic) == ("25", 25_000_000) + assert plan.mint_mode == "public" and plan.sender is None + # tuple refs and case-insensitive keys work too + assert prepare(DEFAULT_REGISTRY, source=("ethereum", "USDC"), destination=("aleo", "usdcx"), + amount="25", recipient=ALEO).route_id == plan.route_id + + +def test_protocol_filter_is_forwarded_and_disambiguates(): + class _Ambiguous: + version = DEFAULT_REGISTRY.version + asset = DEFAULT_REGISTRY.asset + chain = DEFAULT_REGISTRY.chain + seen = [] + + def find_route(self, source, destination, protocol=None): + self.seen.append(protocol) + if protocol is None: + raise AmbiguousRouteError("Multiple bridge routes match; specify protocol") + return DEFAULT_REGISTRY.find_route(source, destination, protocol) + + reg = _Ambiguous() + with pytest.raises(AmbiguousRouteError): + prepare(reg, source="ethereum/usdc", destination="aleo/usdcx", amount="25", recipient=ALEO) + plan = prepare(reg, source="ethereum/usdc", destination="aleo/usdcx", amount="25", + recipient=ALEO, protocol="xreserve") + assert plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and reg.seen == [None, "xreserve"] + with pytest.raises(RouteNotFoundError): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, protocol="hyperlane") + + +def test_xreserve_deposit_steps(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25.5", recipient=ALEO) + assert [s.id for s in plan.steps] == ["source-approval", "source-deposit", + "deposit-attestation", "destination-mint"] + assert [s.kind for s in plan.steps] == ["approve", "deposit", "wait-attestation", "mint"] + assert [s.id for s in plan.steps if s.irreversible] == ["source-deposit"] + assert [s.executor for s in plan.steps] == ["evm-wallet", "evm-wallet", "protocol", "protocol"] + + +def test_xreserve_burn_steps(): + plan = prepare(DEFAULT_REGISTRY, source="aleo/usdcx", destination="ethereum/usdc", + amount="10", recipient=EVM1) + assert [s.id for s in plan.steps] == ["source-burn", "withdrawal-attestation", + "destination-withdrawal", "destination-confirmation"] + assert [s.kind for s in plan.steps] == ["burn", "wait-attestation", "withdraw", "confirm-delivery"] + assert [s.executor for s in plan.steps] == ["aleo-wallet", "protocol", "protocol", "protocol"] + assert [s.id for s in plan.steps if s.irreversible] == ["source-burn"] + + +def test_mint_modes(): + record = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="record") + private = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="private") + assert record.mint_mode == "record" and record.steps[-1].executor == "protocol" + assert private.mint_mode == "private" and private.steps[-1].executor == "aleo-wallet" + with pytest.raises(ConfigurationError, match="only valid.*Aleo"): + prepare(DEFAULT_REGISTRY, source="aleo/usdcx", destination="ethereum/usdc", + amount="10", recipient=EVM1, mint_mode="private") + with pytest.raises(ConfigurationError, match="xReserve"): + prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount="0.1", recipient=ALEO, mint_mode="record") + with pytest.raises(ConfigurationError, match="mint_mode"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="secret") + + +def test_hyperlane_steps_approval_only_on_non_aleo_token_sources(): + inbound = prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount="0.1", recipient=ALEO) + assert [s.id for s in inbound.steps] == ["source-approval", "source-dispatch", + "message-delivery", "destination-confirmation"] + assert [s.kind for s in inbound.steps] == ["approve", "dispatch", "wait-delivery", "confirm-delivery"] + assert inbound.steps[0].executor == "evm-wallet" and inbound.steps[-1].executor == "protocol" + assert [s.id for s in inbound.steps if s.irreversible] == ["source-dispatch"] + + native = prepare(DEFAULT_REGISTRY, source="ethereum/eth", destination="aleo/eth", + amount="0.000000000000000001", recipient=ALEO) + assert [s.id for s in native.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert native.amount_atomic == 1 + + outbound = prepare(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=EVM1) + assert [s.id for s in outbound.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert outbound.steps[0].executor == "aleo-wallet" + + sol = prepare(DEFAULT_REGISTRY, source="solana/sol", destination="aleo/sol", + amount="0.000000001", recipient=ALEO, sender="11111111111111111111111111111111") + assert [s.id for s in sol.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert sol.steps[0].executor == "solana-wallet" and sol.sender == "11111111111111111111111111111111" + + +def test_amount_forms_and_precision(): + by_atomic = prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount_atomic=100_000, recipient=ALEO) + assert (by_atomic.amount, by_atomic.amount_atomic) == ("0.001", 100_000) + with pytest.raises(InvalidAmountError, match="greater than zero"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="0", recipient=ALEO) + with pytest.raises(InvalidAmountError): # 7 fractional digits on a 6-decimal asset + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="0.0000001", recipient=ALEO) + with pytest.raises(InvalidAmountError): # exactly one of amount / amount_atomic + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="1", amount_atomic=1_000_000, recipient=ALEO) + with pytest.raises(InvalidAmountError): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", recipient=ALEO) + + +def test_destination_decimals_are_checked_too(): + class _Reg: + version = DEFAULT_REGISTRY.version + chain = DEFAULT_REGISTRY.chain + find_route = DEFAULT_REGISTRY.find_route + + def asset(self, ref): + asset = DEFAULT_REGISTRY.asset(ref) + if asset.id == "aleo/usdcx": + import dataclasses + return dataclasses.replace(asset, decimals=2) # coarser destination + return asset + + with pytest.raises(InvalidAmountError): + prepare(_Reg(), source="ethereum/usdc", destination="aleo/usdcx", amount="1.001", recipient=ALEO) + assert prepare(_Reg(), source="ethereum/usdc", destination="aleo/usdcx", amount="1.5", + recipient=ALEO).amount_atomic == 1_500_000 + + +def test_recipient_regex(): + with pytest.raises(InvalidRecipientError, match="aleo address format"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="1", recipient="not-an-aleo-address") + with pytest.raises(InvalidRecipientError, match="ethereum address format"): + prepare(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=ALEO) + + +def test_metadata_required_routes_still_plan(): + # veil's prepare only excludes *disabled* routes; quote/execute refuse metadata-required ones. + plan = prepare(DEFAULT_REGISTRY, source="aleo/aleo", destination="ethereum/aleo", + amount="1", recipient=EVM1) + assert plan.route_id == "hyperlane:aleo/aleo->ethereum/aleo" + + +def test_plan_roundtrip_and_resolve_route(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="private", sender=EVM1) + again = Plan.from_dict(plan.to_dict()) + assert again == plan + resolved = resolve_route(DEFAULT_REGISTRY, plan) + assert (resolved.route.id, resolved.source_chain.family, resolved.destination_chain.family) == ( + plan.route_id, "evm", "aleo") + import dataclasses + with pytest.raises(RegistryVersionMismatchError): + resolve_route(DEFAULT_REGISTRY, dataclasses.replace(plan, registry_version="old")) + with pytest.raises(CheckpointInvalidError): + resolve_route(DEFAULT_REGISTRY, dataclasses.replace(plan, protocol="hyperlane")) + + +def _recipient_for(chain_family: str) -> str: + return {"aleo": ALEO, "evm": EVM1, "solana": SOLANA1}[chain_family] + + +def test_prepare_equals_build_plan_for_every_active_route(): + # Controller ruling (task-1-controller-notes.md #4): prepare() is only ever a thin + # validating wrapper around the shared _plan.build_plan — for every active route this + # must hold field-by-field, with amount_atomic=1 and a recipient valid for the destination. + for route in DEFAULT_REGISTRY.routes(include_unavailable=True): + if not route.active: + continue + destination = DEFAULT_REGISTRY.asset(route.destination_asset_id) + destination_chain = DEFAULT_REGISTRY.chain(destination.chain_id) + recipient = _recipient_for(destination_chain.family) + prepared = prepare(DEFAULT_REGISTRY, source=route.source_asset_id, + destination=route.destination_asset_id, amount_atomic=1, + recipient=recipient, protocol=route.protocol) + expected = build_plan(DEFAULT_REGISTRY, route, amount_atomic=1, recipient=recipient, sender=None) + assert prepared == expected, route.id diff --git a/bridge-sdk/tests/test_privacy.py b/bridge-sdk/tests/test_privacy.py new file mode 100644 index 0000000..4314133 --- /dev/null +++ b/bridge-sdk/tests/test_privacy.py @@ -0,0 +1,101 @@ +import pytest + +from aleo_bridge.errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from aleo_bridge.freezelist import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT +from aleo_bridge.privacy import record_amount +from aleo_bridge.types import PrivacyReceipt +from tests.conftest import SIGNER, USDCX_RECORD, USDCX_RECORD_SMALL + +OTHER = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +SOL_RECORD = f"{{ owner: {SIGNER}.private, amount: 300000000u128.private, _nonce: 9group.public }}" +SOL_RECORD_BIG = f"{{ owner: {SIGNER}.private, amount: 900000000u128.private, _nonce: 10group.public }}" + + +def test_record_amount_parser(): + assert record_amount(USDCX_RECORD) == 5_000_000 and record_amount(USDCX_RECORD_SMALL) == 100 + assert record_amount("{ owner: aleo1x.private, _nonce: 1group.public }") is None + assert record_amount("") is None + + +def test_shield_arc20_matches_veil(bridge): + call = bridge.privacy.shield("aleo/eth", amount="0.000000000000000001") + assert (call.program_id, call.function_name, call.inputs) == ("arc20_eth.aleo", "shield", ["1u128"]) + result = call.transact() + assert result == PrivacyReceipt("at1built", "aleo/eth", "0.000000000000000001", 1, "shield") + + +def test_shield_arc22_names_recipient(bridge): + call = bridge.privacy.shield(("aleo", "usdcx"), amount="2.5") + assert (call.program_id, call.function_name, call.inputs) == ("usdcx_stablecoin.aleo", "transfer_public_to_private", [SIGNER, "2500000u128"]) + assert bridge.privacy.shield("aleo/usdcx", amount_atomic=2_500_000, recipient=OTHER).inputs == [OTHER, "2500000u128"] + with pytest.raises(ConfigurationError, match="ARC-20 shield always credits the caller"): + bridge.privacy.shield("aleo/eth", amount="1", recipient=OTHER) + with pytest.raises(InvalidRecipientError): + bridge.privacy.shield("aleo/usdcx", amount="1", recipient="0x1234") + + +def test_unshield_arc20_selects_smallest_covering_record(bridge): + bridge.aleo.record_rows = [{"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD_BIG}, + {"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD}, + {"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD}] + call = bridge.privacy.unshield("aleo/sol", amount="0.25") + assert (call.program_id, call.function_name, call.inputs) == ("arc20_sol.aleo", "unshield", [SOL_RECORD, "250000000u128"]) + 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"): + bridge.privacy.unshield("aleo/sol", amount="0.25", merkle_proof="[x]") + + +def test_unshield_arc20_recipient_must_match_caller(bridge): + bridge.aleo.record_rows = [{"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD}] + call = bridge.privacy.unshield("aleo/sol", amount="0.25", recipient=SIGNER) + assert (call.program_id, call.function_name, call.inputs) == ("arc20_sol.aleo", "unshield", [SOL_RECORD, "250000000u128"]) + with pytest.raises(ConfigurationError, match="ARC-20 unshield always credits the caller"): + bridge.privacy.unshield("aleo/sol", amount="0.25", recipient=OTHER) + + +def test_unshield_arc22_defaults_to_signer_record_and_empty_proof(bridge): + bridge.aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} + call = bridge.privacy.unshield("aleo/usdcx", amount="2.5") + assert (call.program_id, call.function_name) == ("usdcx_stablecoin.aleo", "transfer_private_to_public") + assert call.inputs[:3] == [SIGNER, "2500000u128", USDCX_RECORD] + assert call.inputs[3] == EMPTY_MERKLE_PROOF_PAIR and call.inputs[3].count("0field") == 32 + + +def test_unshield_arc22_accepts_explicit_inputs(bridge): + call = bridge.privacy.unshield("aleo/usdcx", amount="2.5", recipient=OTHER, record=USDCX_RECORD_SMALL, merkle_proof="[custom-proof]") + assert call.inputs == [OTHER, "2500000u128", USDCX_RECORD_SMALL, "[custom-proof]"] + assert bridge.aleo.record_queries == [] # explicit record: no scanner query + + +def test_unsupported_assets_and_zero_amounts(bridge): + with pytest.raises(UnsupportedRouteError, match="does not support shielding"): + bridge.privacy.shield("aleo/aleo", amount="1") + with pytest.raises(UnsupportedRouteError, match="does not support unshielding"): + bridge.privacy.unshield("aleo/aleo", amount="1") + with pytest.raises(UnsupportedRouteError, match="does not support shielding"): + bridge.privacy.shield("ethereum/usdc", amount="1") + with pytest.raises(InvalidAmountError, match="Unshielding amount must be greater than zero"): + bridge.privacy.unshield("aleo/sol", amount="0") + with pytest.raises(InvalidAmountError, match="Shielding amount must be greater than zero"): + bridge.privacy.shield("aleo/sol", amount_atomic=0) + + +def test_select_record_reports_largest_available(bridge): + bridge.aleo.record_rows = [{"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD_SMALL}] + with pytest.raises(InsufficientBalanceError, match="largest available is 100"): + bridge.privacy.select_record("usdcx_stablecoin.aleo", 2_500_000) + bridge.aleo.record_rows = [] + with pytest.raises(InsufficientBalanceError, match="largest available is 0"): + bridge.privacy.select_record("usdcx_stablecoin.aleo", 1) + + +def test_private_burn_defaults_resolve_through_privacy_and_freezelist(bridge): + ONE_LIT = "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" + # The freeze-list root must be readable on chain for the default (unsupplied) merkle_proof= + # to resolve — an unreadable root is now fatal (see test_freezelist.py). + bridge.aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} + call = bridge.xreserve.burn("0x0000000000000000000000000000000000000001", amount="2.5") + assert call.inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, EMPTY_MERKLE_PROOF_PAIR] + assert bridge.aleo.record_queries[-1]["program"] == "usdcx_stablecoin.aleo" diff --git a/bridge-sdk/tests/test_profile.py b/bridge-sdk/tests/test_profile.py new file mode 100644 index 0000000..30d61ea --- /dev/null +++ b/bridge-sdk/tests/test_profile.py @@ -0,0 +1,76 @@ +import json +import os +import stat + +import pytest + +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.profile import DEFAULT_ENDPOINT, Profile + + +def test_profile_created_once_with_private_mode(tmp_path, monkeypatch): + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + profile = Profile.load_or_create(tmp_path / "home") + assert profile.address.startswith("aleo1") and len(profile.address) == 63 + assert profile.private_key.startswith("APrivateKey1") and profile.network == "mainnet" and profile.endpoint == DEFAULT_ENDPOINT + assert stat.S_IMODE(os.stat(tmp_path / "home" / "profile.json").st_mode) == 0o600 + again = Profile.load_or_create(tmp_path / "home", network="testnet", endpoint="https://other.example") + assert (again.address, again.network, again.endpoint) == (profile.address, "mainnet", DEFAULT_ENDPOINT) # creation-only args + data = json.loads((tmp_path / "home" / "profile.json").read_text()) + assert set(data) == {"address", "private_key", "network", "endpoint"} + assert profile.checkpoint_dir == tmp_path / "home" / "checkpoints" and profile.checkpoint_dir.is_dir() + + +def test_profile_imports_key_from_env_or_file(tmp_path, monkeypatch): + from aleo import testnet as net + key = net.PrivateKey.random() + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", str(key)) + assert Profile.load_or_create(tmp_path / "a", network="testnet").address == str(key.address) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY") + key_file = tmp_path / "key.txt" + key_file.write_text(f"{key}\n") + monkeypatch.setenv("BRIDGE_PRIVATE_KEY_FILE", str(key_file)) + assert Profile.load_or_create(tmp_path / "b", network="testnet").address == str(key.address) + + +def test_default_home_and_tilde_expansion(tmp_path, monkeypatch): + monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "x")) + assert Profile.default_home() == tmp_path / "x" + monkeypatch.delenv("ALEO_BRIDGE_HOME") + assert Profile.default_home().name == ".aleo-bridge" + monkeypatch.setenv("HOME", str(tmp_path)) + assert Profile.load_or_create("~/p", network="testnet").home == tmp_path / "p" + + +def test_profile_rejects_unknown_network(tmp_path): + with pytest.raises(ConfigurationError, match="network"): + Profile.load_or_create(tmp_path / "bad", network="devnet") + + +def test_profile_creation_is_exclusive_loser_adopts_winner_key(tmp_path, monkeypatch): + """Two processes racing Profile.load_or_create() on a fresh home must not clobber each other: + the exclusive create (O_EXCL) means the loser adopts the winner's already-written key.""" + from aleo import mainnet as net + + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + home = tmp_path / "home" + winner_key = net.PrivateKey.random() + winner_address = str(winner_key.address) + + def racing_initial_key(network): + # Simulate another process winning the race: it creates the home dir and profile.json + # before this process gets to its own exclusive-open attempt. + home.mkdir(parents=True, exist_ok=True, mode=0o700) + (home / "profile.json").write_text(json.dumps({ + "address": winner_address, "private_key": str(winner_key), "network": network, "endpoint": DEFAULT_ENDPOINT, + })) + loser_key = net.PrivateKey.random() # must NOT end up written or returned + return str(loser_key), str(loser_key.address) + + monkeypatch.setattr("aleo_bridge.profile._initial_key", racing_initial_key) + profile = Profile.load_or_create(home) + assert profile.address == winner_address + assert json.loads((home / "profile.json").read_text())["address"] == winner_address + assert stat.S_IMODE(os.stat(home).st_mode) == 0o700 diff --git a/bridge-sdk/tests/test_quote.py b/bridge-sdk/tests/test_quote.py new file mode 100644 index 0000000..1da052e --- /dev/null +++ b/bridge-sdk/tests/test_quote.py @@ -0,0 +1,64 @@ +import pytest + +from aleo_bridge.errors import (ConfigurationError, InvalidAmountError, RouteUnavailableError, + UnsupportedRouteError) +from aleo_bridge.lifecycle import quote +from aleo_bridge.types import AleoHyperlaneQuote, AleoXReserveQuote +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + + +def test_evm_hyperlane_quote_dispatches_to_eth_and_carries_the_canonical_plan(): + b = FakeBridge() + q = quote(b, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT, + sender=EVM_ADDRESS) + assert q.kind == "evm-hyperlane" and q.plan.route_id == "hyperlane:ethereum/wbtc->aleo/wbtc" + assert q.plan.sender == EVM_ADDRESS and q.native_value_atomic == 100_000 + 1000 + assert b.calls == [("eth.quote_transfer_remote", {"plan": q.plan})] + + +def test_evm_xreserve_quote_passes_mint_mode_and_secret_nonce(): + b = FakeBridge() + q = quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT, + mint_mode="private", secret_nonce="7scalar") + assert q.kind == "evm-xreserve" and q.plan.mint_mode == "private" + assert b.calls[-1] == ("eth.quote_deposit_usdc", {"plan": q.plan, "secret_nonce": "7scalar"}) + + +def test_solana_hyperlane_quote(): + b = FakeBridge(solana=True) + q = quote(b, source="solana/sol", destination="aleo/sol", amount="0.000000001", recipient=ALEO_RECIPIENT, + sender=SOL_ADDRESS) + assert q.kind == "solana-hyperlane" and q.total_lamports == 1 + 7_914_240 + assert b.calls == [("sol.quote_transfer_remote", {"plan": q.plan})] + + +def test_aleo_hyperlane_quote_reads_the_igp_only(): + b = FakeBridge(ethereum=False) + q = quote(b, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + assert isinstance(q, AleoHyperlaneQuote) and q.kind == "aleo-hyperlane" + assert q.payment_microcredits == 8_174_147 and q.gas_limit == 44_000 + assert q.amount_out == "0.000000000000000001" + assert [f.kind for f in q.fees] == ["protocol"] and q.fees[0].amount == "8.174147" and q.fees[0].estimated + assert q.fees[0].asset_id == "aleo/aleo" + assert b.calls == [("hyperlane.quote_gas_payment", "aleo/eth")] + + +def test_aleo_xreserve_quote_is_offline_and_deducts_the_withdrawal_fee(): + b = FakeBridge(ethereum=False) + q = quote(b, source="aleo/usdcx", destination="ethereum/usdc", amount="2.000001", recipient=EVM_ADDRESS) + assert isinstance(q, AleoXReserveQuote) and q.kind == "aleo-xreserve" + assert q.amount_out == "0.000001" and q.withdrawal_fee_atomic == 2_000_000 + assert q.fees == (q.fees[0],) and q.fees[0].kind == "protocol" and q.fees[0].estimated is False + assert (q.fees[0].chain_id, q.fees[0].asset_id, q.fees[0].amount) == ("aleo", "aleo/usdcx", "2") + assert b.calls == [] # no network + with pytest.raises(InvalidAmountError, match="exceed the 2 USDCx withdrawal fee"): + quote(b, source="aleo/usdcx", destination="ethereum/usdc", amount="2", recipient=EVM_ADDRESS) + + +def test_missing_connection_and_unavailable_route(): + b = FakeBridge(ethereum=False) + with pytest.raises(ConfigurationError, match="Ethereum connection"): + quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT) + with pytest.raises(RouteUnavailableError, match="metadata-required"): + quote(b, source="aleo/aleo", destination="ethereum/aleo", amount="1", recipient=EVM_ADDRESS) diff --git a/bridge-sdk/tests/test_recover.py b/bridge-sdk/tests/test_recover.py new file mode 100644 index 0000000..44cbdfc --- /dev/null +++ b/bridge-sdk/tests/test_recover.py @@ -0,0 +1,230 @@ +import json + +import pytest + +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint +from aleo_bridge.errors import (CheckpointInvalidError, RegistryVersionMismatchError, UnsupportedRouteError) +from aleo_bridge.lifecycle import prepare, recover +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +EVM1 = EVM_ADDRESS + + +def _aleo_eth_checkpoint(b, **source): + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM1) + return plan, {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": plan.amount, "recipient": plan.recipient}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": source} + + +def test_version_route_and_registry_checks(): + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1x") + with pytest.raises(CheckpointInvalidError, match="version"): + recover(b, {**cp, "version": 2}) + with pytest.raises(CheckpointInvalidError, match="does not match"): + recover(b, {**cp, "route": {**cp["route"], "id": "hyperlane:aleo/wbtc->ethereum/wbtc"}}) + with pytest.raises(RegistryVersionMismatchError): + recover(b, {**cp, "route": {**cp["route"], "registryVersion": "2020-01-01.old"}}) + with pytest.raises(CheckpointInvalidError, match="intent"): + recover(b, {**cp, "intent": {"source": {"chain": "aleo"}}}) + + +def test_prepared_but_unbroadcast_aleo_transaction_resumes_without_network(): + b = FakeBridge(ethereum=False) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + plan, cp = _aleo_eth_checkpoint(b, preparedTransaction={"transactionId": "at1prepared", + "serializedTransaction": serialized}) + cp["deliveryVerification"] = {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + progress = recover(b, json.dumps(cp)) # JSON string accepted + assert progress.next == "resume" and progress.receipt.status is Status.SOURCE_SUBMISSION_PENDING + assert progress.receipt.id == "at1prepared" + assert progress.receipt.protocol_state == {"routeId": plan.route_id, "preparedTransaction": serialized, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"} + assert b.calls == [] and b.events == [] + with pytest.raises(CheckpointInvalidError, match="id does not match"): + recover(b, {**cp, "source": {"preparedTransaction": {"transactionId": "at1other", "serializedTransaction": serialized}}}) + with pytest.raises(CheckpointInvalidError, match="invalid prepared"): + recover(b, {**cp, "source": {"preparedTransaction": {"transactionId": "at1prepared", "serializedTransaction": "{not json"}}}) + with pytest.raises(CheckpointInvalidError, match="invalid for a prepared Aleo"): + recover(b, {**cp, "destination": {"transactionId": "at1d"}}) + + +def test_submitted_aleo_source_is_observed_once_never_rebroadcast(): + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + progress = recover(b, Checkpoint.from_dict(cp)) + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + b.aleo.confirmed_transactions["at1burn"] = {"status": "accepted"} + progress = recover(b, cp) + assert progress.next == "wait" and progress.receipt.status is Status.DELIVERY_PENDING + assert progress.receipt.source_tx_id == "at1burn" and b.submitted == [] + # Checkpoint.from_dict (which we do not modify) derives receiptId from source/destination when + # absent, and raises its own "no submitted or prepared transaction" error before recover() ever + # sees an empty source — so a receiptId must be supplied for recover()'s OWN check to be reached. + with pytest.raises(CheckpointInvalidError, match="no submitted source transaction"): + recover(b, {**cp, "receiptId": "at1burn", "source": {}}) + with pytest.raises(CheckpointInvalidError, match="invalid for an Aleo"): + recover(b, {**cp, "source": {"transactionId": "at1burn", "approvalTransactionIds": ["0x1"]}}) + + +def test_solana_checkpoint_validates_blockhash_pair_and_reads_status(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + cp = create_checkpoint(plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhash": "recent", + "lastValidBlockHeight": "123456789"}), b.registry) + b.sol.source_status_result = Receipt(id="sig", protocol="hyperlane", status=Status.EXPIRED, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhashExpired": True, + "sourceError": "Solana transaction expired before confirmation: sig"}) + progress = recover(b, cp) + assert progress.next == "failed" and progress.receipt.status is Status.EXPIRED + assert progress.error == "Solana transaction expired before confirmation: sig" + assert b.calls == [("sol.source_status", Status.SOURCE_CONFIRMING)] + d = cp.to_dict() + with pytest.raises(CheckpointInvalidError, match="blockhash"): + recover(b, {**d, "source": {"transactionId": "sig", "blockhash": "recent"}}) + with pytest.raises(CheckpointInvalidError, match="no submitted source transaction"): + recover(b, {**d, "source": {"blockhash": "recent", "lastValidBlockHeight": "1"}}) + + +def test_evm_hyperlane_delegates_to_eth_recover_source(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT, + sender=EVM1) + cp = create_checkpoint(plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}), + b.registry) + b.eth.recover_result = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM1}) + progress = recover(b, cp) + assert progress.next == "resume" and b.calls[0][0] == "eth.recover_source" and b.calls[0][2] is False + with pytest.raises(CheckpointInvalidError, match="destination transaction"): + recover(b, {**cp.to_dict(), "destination": {"transactionId": "at1x"}}) + + +def test_evm_xreserve_recovery_paths(): + b = FakeBridge(environment="testnet") + plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT, mint_mode="private", sender=EVM1) + base = create_checkpoint(plan, Receipt(id="0x" + "22" * 32, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="0x" + "22" * 32, protocol_state={"routeId": plan.route_id}), + b.registry).to_dict() + attested = Receipt(id="0x" + "cc" * 32, protocol="xreserve", status=Status.ATTESTATION_PENDING, source_tx_id="0x" + "22" * 32, + protocol_state={"routeId": plan.route_id, "mintMode": "private", "intendedRecipient": ALEO_RECIPIENT, + "messageHash": "0x" + "cc" * 32, "nonce": "0x" + "dd" * 32, + "bridgeProgram": "test_usdcx_bridge_v2.aleo"}) + b.eth.recover_result = attested + # ATTESTATION_PENDING → one get_status (Circle not ready) → wait + assert recover(b, base).next == "wait" + assert [c[0] for c in b.calls] == ["eth.recover_source", "xreserve.is_delivered", "xreserve.get_attestation"] + # submitted destination tx → DESTINATION_CONFIRMING, observed once + b.calls.clear() + b.aleo.confirmed_transactions["at1private"] = {"status": "accepted"} + done = recover(b, {**base, "destination": {"transactionId": "at1private"}}) + assert done.next == "done" and done.receipt.destination_tx_id == "at1private" + # both prepared and submitted destination → invalid + serialized = json.dumps({"type": "execute", "id": "at1mint", "fee": {}}) + with pytest.raises(CheckpointInvalidError, match="both prepared and submitted"): + recover(b, {**base, "destination": {"transactionId": "at1private", + "preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + # prepared destination survives only while DESTINATION_ACTION_REQUIRED + b.xreserve.delivered_nonces.clear() + with pytest.raises(CheckpointInvalidError, match="no longer valid"): + recover(b, {**base, "destination": {"preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + from aleo_bridge.types import Attestation + b.xreserve.attestations["0x" + "cc" * 32] = Attestation(b"\x00" * 305, bytes.fromhex("cc" * 32), b"\x11" * 65, "complete") + ready = recover(b, {**base, "destination": {"preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + assert ready.next == "complete" and ready.receipt.id == "at1mint" + assert ready.receipt.protocol_state["preparedDestinationTransaction"] == serialized + + +def test_unsupported_route_and_terminal_cleanup(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + store.save(Checkpoint.from_dict(cp)) + b.aleo.confirmed_transactions["at1burn"] = {"status": "rejected"} + progress = recover(b, cp) + assert progress.next == "failed" and store.list() == [] + burn = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM1) + ok = recover(b, create_checkpoint(burn, Receipt(id="at1b", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1b", protocol_state={"routeId": burn.route_id}), b.registry)) + assert ok.next == "wait" + + +def test_malformed_delivery_verification_raises_checkpoint_invalid(): + # Item 8 (carried from Task 7 review): a hand-edited/foreign checkpoint whose + # deliveryVerification block is missing a key or holds a non-digit value must raise + # CheckpointInvalidError, never KeyError, before any network read. + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": "100"}}) # missing key + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "abc"}}) + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": None, "expectedIncreaseAtomic": "1"}}) + assert b.calls == [] and b.events == [] + + +def test_terminal_cleanup_deletes_by_checkpoint_id_not_receipt_id(tmp_path): + # Item 7 (carried from Task 7 review): a Solana checkpoint whose stored id (the source + # signature) differs from the id the refreshed receipt ends up carrying (EXPIRED status + # can flip the receipt id to a message id). _finish must delete the record keyed on + # cp.id ("sig"), never one keyed on receipt.id ("msg-divergent"). + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + cp = create_checkpoint(plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": plan.route_id, + "blockhash": "recent", + "lastValidBlockHeight": "123456789"}), + b.registry) + assert cp.id == "sig" + store.save(cp) + b.sol.source_status_result = Receipt(id="msg-divergent", protocol="hyperlane", status=Status.EXPIRED, + source_tx_id="sig", + protocol_state={"routeId": plan.route_id, + "sourceError": "Solana transaction expired before confirmation: sig"}) + progress = recover(b, cp) + assert progress.next == "failed" and progress.receipt.id == "msg-divergent" + assert store.load("sig") is None + assert store.load("msg-divergent") is None # nothing was ever stored under this key to begin with + + +def test_recovered_plan_round_trips_through_checkpoint(): + # Controller ruling (task-7-controller-notes.md #1): _plan_from_intent rebuilds the plan via + # prepare(), which is proven field-identical to build_plan for every active route + # (tests/test_prepare.py::test_prepare_equals_build_plan_for_every_active_route). Confirm the + # round trip (create_checkpoint -> to_dict -> recover's internal from_dict/_plan_from_intent) + # holds for an EVM, a Solana and an Aleo-origin route by checking recover()'s Progress.plan. + b = FakeBridge(solana=True) + + evm_plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, sender=EVM1) + evm_cp = create_checkpoint(evm_plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": evm_plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]}), b.registry) + b.eth.recover_result = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": evm_plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + assert recover(b, evm_cp).plan == evm_plan + + sol_plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sol_cp = create_checkpoint(sol_plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": sol_plan.route_id}), + b.registry) + assert recover(b, sol_cp).plan == sol_plan + + aleo_plan, aleo_cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + assert recover(b, aleo_cp).plan == aleo_plan diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py new file mode 100644 index 0000000..114ff24 --- /dev/null +++ b/bridge-sdk/tests/test_registry.py @@ -0,0 +1,324 @@ +import re + +import pytest + +from aleo_bridge import _registry_data as data +from aleo_bridge.errors import AmbiguousRouteError, ConfigurationError, RouteNotFoundError +from aleo_bridge.registry import (DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, + validate_registry) + +REG = DEFAULT_REGISTRY +MAILBOX = { + "aleoMailboxStateVerified": True, "aleoMailboxProgram": "hyp_mailbox.aleo", "aleoMailboxProgramEdition": 0, + "aleoMailboxLocalDomain": 1634493807, "aleoMailboxObservedNonce": 170, "aleoMailboxObservedProcessCount": 291, + "aleoMailboxDefaultIsm": "aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk", + "aleoMailboxDefaultHook": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", +} + + +def test_shape_and_version(): + assert REG.version == "2026-08-31.solana-deposits.1" + assert len(REG.chains()) == 7 and len(REG.assets()) == 19 + assert len(REG.routes(include_unavailable=True)) == 22 + assert len(REG.routes()) == 22 # nothing is 'disabled' in this snapshot; metadata-required stays visible + assert validate_registry(REG) is REG + assert REG.routes(include_unavailable=True)[0].metadata["xReserveContract"] == "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" + + +def test_chains(): + assert [c.id for c in REG.chains()] == ["aleo", "ethereum", "solana", "base", "hyperevm", "aleo-testnet", "sepolia"] + assert [c.id for c in REG.chains(environment="testnet")] == ["aleo-testnet", "sepolia"] + aleo = REG.chain("aleo") + assert (aleo.display_name, aleo.family, aleo.environment, aleo.native_symbol) == ("Aleo", "aleo", "mainnet", "ALEO") + assert aleo.protocol_domains == {"xreserve": 10002, "hyperlane": 1634493807} + assert REG.chain("ethereum").protocol_domains == {"xreserve": 0, "hyperlane": 1} + assert REG.chain("solana").protocol_domains == {"hyperlane": 1399811149} + assert REG.chain("base").protocol_domains == {} and REG.chain("hyperevm").native_symbol == "HYPE" + assert REG.chain("aleo-testnet").protocol_domains == {"xreserve": 10002, "hyperlane": 1617853565} + assert REG.chain("sepolia").protocol_domains == {"hyperlane": 11155111} + with pytest.raises(RouteNotFoundError): + REG.chain("bitcoin") + + +def test_assets_and_lookups(): + wbtc = REG.asset("aleo/wbtc") + assert wbtc == REG.asset(("aleo", "wbtc")) == REG.asset("ALEO/WBTC") + assert (wbtc.key, wbtc.chain_id, wbtc.symbol, wbtc.name, wbtc.decimals, wbtc.kind) == \ + ("wbtc", "aleo", "WBTC", "Hyperlane WBTC", 8, "token") + assert wbtc.locator == Locator("aleo-program", "hyp_warp_token_wbtc_v2.aleo", + "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf") + assert wbtc.privacy == Privacy("arc20", "arc20_wbtc.aleo") + assert wbtc.address_regex == "^aleo1[0-9a-z]{58}$" + usdcx = REG.asset("aleo/usdcx") + assert usdcx.locator == Locator("aleo-program", "usdcx_stablecoin.aleo") and usdcx.privacy == Privacy("arc22", "usdcx_stablecoin.aleo") + assert REG.asset("aleo/aleo").privacy is None and REG.asset("aleo/usad").locator == Locator("aleo-program", "usad_stablecoin.aleo") + assert REG.asset("ethereum/usdc").locator == Locator("evm-contract", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + assert REG.asset("ethereum/eth").locator == Locator("native", "ETH") and REG.asset("ethereum/eth").kind == "native" + assert REG.asset("ethereum/wbtc").locator.value == "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + assert REG.asset("ethereum/usdt").locator.value == "0xdAC17F958D2ee523a2206206994597C13D831ec7" + assert REG.asset("sepolia/usdc").locator.value == "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + assert REG.asset("aleo-testnet/usdcx").privacy == Privacy("arc22", "test_usdcx_stablecoin.aleo") + for no_locator in ("ethereum/aleo", "ethereum/usad", "solana/aleo", "base/aleo", "hyperevm/aleo"): + assert REG.asset(no_locator).locator is None + assert REG.asset("solana/sol").address_regex == "^[1-9A-HJ-NP-Za-km-z]{32,44}$" + assert REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001") + assert not REG.asset("aleo/usdcx").matches_address("0xabc") + # re.fullmatch, not re.search: a trailing newline must not sneak past the "$" anchor + assert not REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001\n") + assert [a.id for a in REG.assets(chain="aleo")] == ["aleo/aleo", "aleo/usdcx", "aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol", "aleo/usad"] + assert [a.id for a in REG.assets(symbol="aleo")] == ["aleo/aleo", "ethereum/aleo", "solana/aleo", "base/aleo", "hyperevm/aleo"] + assert [a.id for a in REG.assets(environment="testnet")] == ["aleo-testnet/usdcx", "sepolia/usdc"] + for bad in ("aleo/doge", "doge", ("aleo", "doge"), "a/b/c"): + with pytest.raises(RouteNotFoundError): + REG.asset(bad) + + +def test_usdcx_only_via_xreserve_and_others_via_hyperlane(): + usdcx = [r for r in REG.routes(include_unavailable=True) if "usdcx" in r.source_asset_id or "usdcx" in r.destination_asset_id] + assert usdcx and all(r.protocol == "xreserve" for r in usdcx) + for symbol in ("ETH", "WBTC", "USDT", "SOL", "ALEO", "USAD"): + routes = REG.routes(symbol=symbol, include_unavailable=True) + assert routes and all(r.protocol == "hyperlane" for r in routes), symbol + + +def test_xreserve_routes(): + xr = REG.routes(protocol="xreserve", include_unavailable=True) + assert [r.id for r in xr] == ["xreserve:ethereum/usdc->aleo/usdcx", "xreserve:aleo/usdcx->ethereum/usdc", + "xreserve:sepolia/usdc->aleo-testnet/usdcx", "xreserve:aleo-testnet/usdcx->sepolia/usdc"] + assert all(r.availability == "active" and r.active for r in xr) + assert all(r.metadata["ethereumDestinationDomain"] == 0 and r.metadata["arcDestinationDomain"] == 26 for r in xr) + assert all(r.source == "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains" for r in xr) + main = REG.route("xreserve:aleo/usdcx->ethereum/usdc").metadata + assert main == { + "xReserveContract": "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", "sourceChainId": 1, "sourceDomain": 0, + "ethereumDestinationDomain": 0, "arcDestinationDomain": 26, "remoteDomain": 10002, + "remoteToken": "usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711", + "minimumAmountAtomic": "2000000", "withdrawalFeeAtomic": "2000000", "maxFeeAtomic": "100000", + "bridgeProgram": "usdcx_bridge_v2.aleo", "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api.circle.com/v1/attestations", + } + test = REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx").metadata + assert test["xReserveContract"] == "0x008888878f94C0d87defdf0B07f46B93C1934442" and test["sourceChainId"] == 11155111 + assert test["remoteToken"] == "test_usdcx_stablecoin.aleo" and test["bridgeProgram"] == "test_usdcx_bridge_v2.aleo" + assert test["remoteTokenBytes32"] == "0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401" + assert test["attestationBaseUrl"] == "https://xreserve-api-testnet.circle.com/v1/attestations" + assert REG.route("xreserve:ethereum/usdc->aleo/usdcx").deployment_id == "xreserve-usdcx-aleo" + assert REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx").deployment_id == "xreserve-usdcx-aleo-testnet" + + +def test_inbound_ethereum_hyperlane_routes(): + inbound = [REG.route(i) for i in ("hyperlane:ethereum/eth->aleo/eth", "hyperlane:ethereum/wbtc->aleo/wbtc", "hyperlane:ethereum/usdt->aleo/usdt")] + assert all(r.active for r in inbound) + for r in inbound: + m = r.metadata + assert m["registryCommit"] == "2621c16f2db1ccb46643265c110dac5ca2c7c51a" + assert m["sourceChainId"] == 1 and m["destinationDomain"] == 1634493807 + assert m["mailboxAddress"] == "0xc005dc82818d67AF737725bD4bf75435d065D239" + assert m["interchainGasPaymaster"] == "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611" + assert m["interchainSecurityModule"] == "0x0000000000000000000000000000000000000000" + for k, v in MAILBOX.items(): + assert m[k] == v + assert r.source == "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes" + eth, wbtc, usdt = (r.metadata for r in inbound) + assert (eth["routerAddress"], eth["routerType"]) == ("0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", "native") + assert eth["destinationRouter"] == "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8" + assert (wbtc["routerAddress"], wbtc["routerType"], wbtc["tokenAddress"]) == \ + ("0x20CDC85778b732073F7EecEF3DF25c0d310f8772", "collateral", "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599") + assert (usdt["routerAddress"], usdt["tokenAddress"], usdt["requiresApprovalReset"]) == \ + ("0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", "0xdAC17F958D2ee523a2206206994597C13D831ec7", True) + assert "requiresApprovalReset" not in wbtc + + +def test_aleo_origin_withdrawals_are_active_and_pinned(): + ids = ["hyperlane:aleo/eth->ethereum/eth", "hyperlane:aleo/wbtc->ethereum/wbtc", + "hyperlane:aleo/usdt->ethereum/usdt", "hyperlane:aleo/sol->solana/sol"] + routes = [REG.route(i) for i in ids] + assert all(r.active for r in routes) + assert all(r.metadata["aleoPlaceholderConfiguration"] is False for r in routes) + assert all(r.metadata["aleoWithdrawalReviewedAt"] == "2026-08-26" for r in routes) + assert all(r.metadata["aleoHookManagerProgram"] == "hyp_hook_manager.aleo" for r in routes) + assert [r.metadata["aleoRouterProgram"] for r in routes] == [ + "hyp_warp_token_eth_v2.aleo", "hyp_warp_token_wbtc_v2.aleo", "hyp_warp_token_usdt_v2.aleo", "hyp_warp_token_sol_v2.aleo"] + for r in routes: + m = r.metadata + assert m["aleoAppMetadataVerified"] is True and m["aleoRemoteRouterVerified"] is True + assert m["aleoAllowanceSpendersVerified"] is True and m["aleoUnusedAllowancesVerified"] is True + assert m["aleoTokenType"] == "1" + assert m["aleoIsm"] == m["aleoHook"] == "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + assert m["aleoAllowanceSpender0"] == "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" + assert m["aleoAllowanceSpender1"] == m["aleoAllowanceSpender2"] == m["aleoAllowanceSpender3"] == \ + "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + assert m["aleoAllowanceAmount1"] == m["aleoAllowanceAmount2"] == m["aleoAllowanceAmount3"] == "0" + # slot 0's pinned "0" is a placeholder; the live IGP quote is fetched at execution time (out of + # scope for the registry), matching veil's default.ts/dist behavior (placeholderFields mechanism). + assert m["aleoAllowanceAmount0"] == "0" + for k, v in MAILBOX.items(): + assert m[k] == v + + +def test_eth_wbtc_usdt_sol_metadata_literals(): + eth = REG.route("hyperlane:aleo/eth->ethereum/eth").metadata + assert eth["aleoTokenOwner"] == "aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r" + assert eth["aleoTokenId"] == "133188123661477349522757068766864658505569365361420630212878794317749195359field" + assert (eth["aleoLocalDecimals"], eth["aleoRemoteDecimals"], eth["aleoProgramEdition"], eth["aleoDestinationDomain"]) == (18, 18, 0, 1) + assert eth["aleoRemoteRouterEvmAddress"] == "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" and eth["aleoRemoteRouterGas"] == "44000" + assert eth["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8]" + assert eth["aleoSampleTransferSource"] == "https://explorer.provable.com/transaction/at1vu0yckkms887zkl3qz7plnncd56jtf5zeal4uj2808upsjkusy8q7yp9v8" + assert eth["routerAddress"] == "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" # ETH_HYPERLANE_METADATA is merged into the reverse route too + + wbtc = REG.route("hyperlane:aleo/wbtc->ethereum/wbtc").metadata + assert wbtc["aleoTokenOwner"] == "aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95" + assert wbtc["aleoTokenId"] == "1505227928464760254508513036497943623956572091841806589002910775534260084309field" + assert (wbtc["aleoLocalDecimals"], wbtc["aleoRemoteDecimals"], wbtc["aleoProgramEdition"]) == (8, 8, 0) + assert wbtc["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8]" + assert wbtc["aleoRemoteRouterGas"] == "68000" + assert wbtc["aleoProgramSource"] == "https://explorer.provable.com/program/hyp_warp_token_wbtc_v2.aleo" + assert wbtc["aleoAppMetadataSource"] == "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/app_metadata/true" + assert wbtc["aleoRemoteRouterSource"] == "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/remote_routers/1u32" + assert wbtc["aleoAppMetadataReviewedAt"] == wbtc["aleoRemoteRouterReviewedAt"] == "2026-08-17" + + usdt = REG.route("hyperlane:aleo/usdt->ethereum/usdt").metadata + assert usdt["aleoTokenOwner"] == "aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z" + assert usdt["aleoTokenId"] == "8295938150000417034830036849466229528602563851235385582732969109393809606969field" + assert (usdt["aleoLocalDecimals"], usdt["aleoRemoteDecimals"], usdt["aleoProgramEdition"], usdt["aleoScale"]) == (6, 18, 1, "1000000000000") + assert usdt["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8]" + assert usdt["aleoRemoteRouterGas"] == "68000" and usdt["aleoSampleTransferDestinationDomain"] == 56 + assert usdt["aleoSampleTransferSource"] == "https://explorer.provable.com/transaction/at19caeeee8v3xc4kfwen4tx89f0tnggrpjp0anrhq2ca3y82xr9q8qyz8a9r" + assert usdt["aleoHyperlaneConfigSource"] == "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/USDT/aleo-config.yaml" + + sol = REG.route("hyperlane:aleo/sol->solana/sol").metadata + assert sol["aleoTokenOwner"] == "aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45" + assert sol["aleoTokenId"] == "6148061383892805373029428966764338809222769879628268522058032128225601478383field" + assert (sol["aleoLocalDecimals"], sol["aleoRemoteDecimals"], sol["aleoDestinationDomain"]) == (9, 9, 1399811149) + assert sol["aleoRemoteRouterSolanaAddress"] == "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + assert sol["aleoRemoteRouterRecipient"] == "[112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8]" + assert sol["aleoRemoteRouterGas"] == "300000" + assert sol["aleoSampleTransitionId"] == "au15fg39h53h55tkj0nexrme3k6pvgxngxapcyajdhf06jcg3cyeugq5kd7hg" + assert "routerAddress" not in sol # no Ethereum common block on the SOL withdrawal + + +def test_solana_deposit_route_metadata(): + r = REG.route("hyperlane:solana/sol->aleo/sol") + assert r.active and r.deployment_id == "SOL/aleo" + expected = { + "warpProgramAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", "tokenPda": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "nativeCollateralPda": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", "dispatchAuthorityPda": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "mailboxProgramAddress": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", "mailboxOutboxPda": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "igpProgramAddress": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", "igpProgramDataPda": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "igpAccount": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", "igpOverheadAccount": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "splNoopProgramAddress": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", "destinationDomain": 1634493807, + "destinationGasAmount": "464000", "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", "solanaReviewedAt": "2026-08-31", + "solanaConfigSource": "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml", + } + for k, v in {**expected, **MAILBOX}.items(): + assert r.metadata[k] == v, k + + +def test_metadata_required_routes(): + pairs = [("aleo/aleo", "ethereum/aleo"), ("aleo/aleo", "solana/aleo"), ("aleo/aleo", "base/aleo"), ("aleo/aleo", "hyperevm/aleo")] + for left, right in pairs: + for rid in (f"hyperlane:{left}->{right}", f"hyperlane:{right}->{left}"): + r = REG.route(rid) + assert r.availability == "metadata-required" and not r.active and r.deployment_id == "ALEO/aleo" + assert dict(r.metadata) == MAILBOX | { + "aleoHookManagerProgram": "hyp_hook_manager.aleo", + "aleoHookManagerProgramSource": "https://explorer.provable.com/program/hyp_hook_manager.aleo", + "aleoMailboxProgramSource": "https://explorer.provable.com/program/hyp_mailbox.aleo", + "aleoMailboxMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_mailbox.aleo/mapping/mailbox/true", + "aleoMailboxMetadataReviewedAt": "2026-08-17", + } + usad_in = REG.route("hyperlane:ethereum/usad->aleo/usad") + assert usad_in.availability == "metadata-required" and usad_in.deployment_id == "USAD/aleo" + usad_out = REG.route("hyperlane:aleo/usad->ethereum/usad") + m = usad_out.metadata + assert usad_out.availability == "metadata-required" and m["aleoPlaceholderConfiguration"] is True + assert m["aleoRouterProgram"] == "hyp_warp_token_usad_v2.aleo" and m["aleoDestinationDomain"] == 1 + assert m["aleoTokenType"] == "0" and m["aleoTokenId"] == "0field" and m["aleoRemoteRouterGas"] == "0" + assert m["aleoTokenOwner"] == m["aleoIsm"] == m["aleoHook"] == "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" + assert m["aleoRemoteRouterRecipient"] == "[" + ", ".join(["0u8"] * 32) + "]" and m["aleoRecipient"] == "[0u128, 0u128]" + assert all(m[f"aleoAllowanceAmount{i}"] == "0" for i in range(4)) + + +def test_route_filters_and_find_route(): + assert [r.id for r in REG.routes(source="aleo", protocol="xreserve")] == ["xreserve:aleo/usdcx->ethereum/usdc"] + assert [r.id for r in REG.routes(source="aleo/wbtc")] == ["hyperlane:aleo/wbtc->ethereum/wbtc"] + assert [r.id for r in REG.routes(destination="aleo", symbol="wbtc")] == ["hyperlane:ethereum/wbtc->aleo/wbtc"] + assert len(REG.routes(environment="testnet")) == 2 and len(REG.routes(environment="mainnet")) == 20 + assert len(REG.routes(source="solana")) == 2 # SOL deposit + metadata-required ALEO + assert REG.find_route("aleo/wbtc", "ethereum/wbtc").id == "hyperlane:aleo/wbtc->ethereum/wbtc" + assert REG.find_route(("ethereum", "usdc"), ("aleo", "usdcx"), protocol="xreserve").id == "xreserve:ethereum/usdc->aleo/usdcx" + assert REG.find_route("aleo/usad", "ethereum/usad").availability == "metadata-required" # visible, refused later + with pytest.raises(RouteNotFoundError): + REG.find_route("aleo/wbtc", "solana/sol") + with pytest.raises(RouteNotFoundError): + REG.find_route("aleo/wbtc", "ethereum/wbtc", protocol="xreserve") + with pytest.raises(RouteNotFoundError): + REG.route("hyperlane:aleo/doge->ethereum/doge") + # A synthetic duplicate pair across protocols is ambiguous without protocol= + dup = Route("xreserve:aleo/wbtc->ethereum/wbtc", "xreserve", "mainnet", "aleo/wbtc", "ethereum/wbtc", "active", None, None, {}) + reg2 = Registry(REG.version, REG.chains(), REG.assets(), [*REG.routes(include_unavailable=True), dup]) + with pytest.raises(AmbiguousRouteError): + reg2.find_route("aleo/wbtc", "ethereum/wbtc") + assert reg2.find_route("aleo/wbtc", "ethereum/wbtc", protocol="hyperlane").protocol == "hyperlane" + # disabled routes are hidden from routes() and find_route() unless include_unavailable + off = Route("hyperlane:aleo/eth->ethereum/eth", "hyperlane", "mainnet", "aleo/eth", "ethereum/eth", "disabled", None, None, {}) + reg3 = Registry(REG.version, REG.chains(), REG.assets(), [off]) + assert reg3.routes() == [] and reg3.routes(include_unavailable=True) == [off] + with pytest.raises(RouteNotFoundError): + reg3.find_route("aleo/eth", "ethereum/eth") + + +def test_route_meta_helpers(): + r = REG.route("hyperlane:aleo/eth->ethereum/eth") + assert r.meta_str("aleoRouterProgram") == "hyp_warp_token_eth_v2.aleo" + assert r.meta_int("aleoDestinationDomain") == 1 and r.meta_int("missing", 7) == 7 + with pytest.raises(ConfigurationError, match="aleoNope is missing"): + r.meta_str("aleoNope") + with pytest.raises(ConfigurationError, match="invalid"): + r.meta_int("aleoMailboxStateVerified") # bool is not an int here + + +def _with(**overrides) -> Registry: + base = dict(version=REG.version, chains=REG.chains(), assets=REG.assets(), routes=REG.routes(include_unavailable=True)) + base.update(overrides) + return Registry(base["version"], base["chains"], base["assets"], base["routes"]) + + +def test_validation_failures(): + from dataclasses import replace + r0 = REG.routes(include_unavailable=True)[0] + with pytest.raises(ConfigurationError, match="unknown source asset missing/asset"): + validate_registry(_with(routes=[replace(r0, source_asset_id="missing/asset")])) + with pytest.raises(ConfigurationError, match="Duplicate bridge route id"): + validate_registry(_with(routes=[r0, r0])) + a0 = REG.assets()[0] + with pytest.raises(ConfigurationError, match="Duplicate bridge asset key"): + validate_registry(_with(assets=[a0, replace(a0, id=a0.id + "-duplicate")], routes=[])) + with pytest.raises(ConfigurationError, match="invalid address validation regex"): + validate_registry(_with(assets=[replace(a0, address_regex="[")], routes=[])) + usdc = REG.asset("ethereum/usdc") + with pytest.raises(ConfigurationError, match="privacy capability on a non-Aleo chain"): + validate_registry(_with(assets=[replace(a, privacy=Privacy("arc20", "arc20_usdc.aleo")) if a.id == usdc.id else a for a in REG.assets()])) + with pytest.raises(ConfigurationError, match="empty privacy program"): + validate_registry(_with(assets=[replace(a, privacy=Privacy("arc20", "")) if a.id == "aleo/sol" else a for a in REG.assets()])) + with pytest.raises(ConfigurationError, match="crosses registry environments"): + validate_registry(_with(routes=[replace(r0, environment="testnet")])) + with pytest.raises(ConfigurationError, match="missing required Solana Hyperlane metadata"): + sol = REG.route("hyperlane:solana/sol->aleo/sol") + incomplete = {k: v for k, v in sol.metadata.items() if k != "igpAccount"} + validate_registry(_with(routes=[replace(r, metadata=incomplete) if r.id == sol.id else r for r in REG.routes(include_unavailable=True)])) + with pytest.raises(ConfigurationError, match="Duplicate bridge chain id"): + validate_registry(_with(chains=[*REG.chains(), REG.chain("aleo")])) + with pytest.raises(ConfigurationError, match="version must not be empty"): + validate_registry(_with(version=" ")) + + +def test_data_module_is_plain_literals(): + assert data.REGISTRY_VERSION == REG.version + assert len(data.CHAINS) == 7 and len(data.ASSETS) == 19 and len(data.ROUTES) == 22 + assert all(isinstance(c, dict) for c in data.CHAINS) and all(isinstance(r["metadata"], dict) for r in data.ROUTES) + assert re.compile(data.ALEO_ADDRESS).match("aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n") diff --git a/bridge-sdk/tests/test_resume_complete.py b/bridge-sdk/tests/test_resume_complete.py new file mode 100644 index 0000000..a54670a --- /dev/null +++ b/bridge-sdk/tests/test_resume_complete.py @@ -0,0 +1,389 @@ +"""``lifecycle.resume`` and ``lifecycle.complete`` — the two caller-boundary verbs. + +``resume`` continues an interrupted SOURCE leg: an Aleo leg rebroadcasts the checkpointed bytes +byte-for-byte (a duplicate answer means the first broadcast won and counts as success), an EVM leg +re-scans source history first and only authorizes the one step history proves is still missing. +``complete`` submits the single user-signed Aleo destination transaction a private USDCx mint +needs, and is idempotent through the same rebroadcast rule. + +The funds-critical invariants under test: neither verb ever repeats an irreversible step, veil's +two resume guards (hook-data commitment, surviving allowance) refuse rather than guess, a private +resume/complete without its ``secret_nonce`` is refused before any RPC, and the secret nonce never +reaches a checkpoint, a protocol_state, or a Progress. +""" +import json + +import pytest +from aleo import AleoNetworkError + +from aleo_bridge._calls import is_duplicate_submission +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import (AttestationError, CheckpointInvalidError, ConfigurationError, + NotResumableError) +from aleo_bridge.lifecycle import (complete, is_duplicate_broadcast_error, prepare, resume, + submit_serialized) +from aleo_bridge.types import Receipt, Status, to_progress +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge +from tests.test_get_status import SIG, _inbound_private + +APPROVAL = "0x" + "11" * 32 + + +def _prepared_progress(b): + """An Aleo-origin transfer proved but never broadcast — exactly what ``execute`` checkpoints + between ``delegate_prepared`` and ``submit_prepared``.""" + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + receipt = Receipt(id="at1prepared", protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": serialized, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + return plan, serialized, to_progress(plan, receipt) + + +def _xreserve_progress(b, **plan_kw): + plan = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, **plan_kw) + receipt = Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": EVM_ADDRESS, + "hookData": "0x" + b.eth.hook_data.hex()}) + return plan, receipt + + +# ── duplicate-broadcast classification (plan 1's rule, not a second one) ────── + +def test_duplicate_detection_is_plan_1s_rule_and_refuses_double_spend_shapes(): + assert is_duplicate_broadcast_error is is_duplicate_submission + assert is_duplicate_broadcast_error(AleoNetworkError("Transaction 'at1x' already exists in the ledger", status=400)) + assert is_duplicate_broadcast_error(AleoNetworkError("transaction at1x already exists in the memory pool")) + # a DIFFERENT transaction colliding with this one's records is a real failure, never success + assert not is_duplicate_broadcast_error(AleoNetworkError("Duplicate serial number found in transaction")) + assert not is_duplicate_broadcast_error(AleoNetworkError("duplicate output id in transaction")) + assert not is_duplicate_broadcast_error(AleoNetworkError("Duplicate transaction at1x")) + assert not is_duplicate_broadcast_error(AleoNetworkError("Invalid transaction: fee verification failed", status=400)) + + +def test_submit_serialized_refuses_an_id_the_node_did_not_echo(): + b = FakeBridge(ethereum=False) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + assert submit_serialized(b, serialized, "at1prepared") == "at1prepared" + b.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1prepared"): + submit_serialized(b, serialized, "at1prepared") + + +# ── resume: Aleo source ─────────────────────────────────────────────────────── + +def test_resume_rebroadcasts_identical_bytes_and_treats_a_duplicate_as_success(): + b = FakeBridge(ethereum=False) + plan, serialized, progress = _prepared_progress(b) + cps = [] + out = resume(b, progress, on_checkpoint=cps.append) + assert b.aleo.submitted == [serialized] # byte-for-byte, never re-proved + assert out.next == "wait" and out.receipt.status is Status.SOURCE_CONFIRMING + assert out.receipt.source_tx_id == "at1prepared" + assert out.receipt.protocol_state == {"routeId": plan.route_id, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"} # bytes discarded + assert len(cps) == 1 and cps[0].source == {"transactionId": "at1prepared"} + assert cps[0].delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + + # the node says it already knows this transaction: the earlier broadcast won the race + b2 = FakeBridge(ethereum=False) + b2.aleo.duplicate_on_submit = True + assert resume(b2, progress).receipt.status is Status.SOURCE_CONFIRMING + + # any other node rejection is a real failure and propagates + b3 = FakeBridge(ethereum=False) + + def invalid(tx): + raise AleoNetworkError("Invalid transaction: fee verification failed", status=400) + + b3.aleo.network.submit_transaction = invalid + with pytest.raises(AleoNetworkError): + resume(b3, progress) + + # a node that answers with a different id never gets recorded as this transfer's transaction + b4 = FakeBridge(ethereum=False) + b4.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1prepared"): + resume(b4, progress) + + +def test_resume_replaces_the_prepared_checkpoint_in_the_bound_store(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan, serialized, progress = _prepared_progress(b) + from aleo_bridge.checkpoint import create_checkpoint + store.save(create_checkpoint(plan, progress.receipt, b.registry)) + assert store.list()[0].source["preparedTransaction"]["serializedTransaction"] == serialized + resume(b, progress) + saved = store.list() + assert [c.id for c in saved] == ["at1prepared"] + assert saved[0].source == {"transactionId": "at1prepared"} # the unbroadcast bytes are gone + + +def test_resume_refuses_a_wrong_state_a_foreign_route_and_mismatched_bytes(): + b = FakeBridge(ethereum=False) + plan, serialized, progress = _prepared_progress(b) + with pytest.raises(NotResumableError, match="SOURCE_SUBMISSION_PENDING"): + resume(b, to_progress(plan, progress.receipt.replace(status=Status.SOURCE_CONFIRMING))) + with pytest.raises(NotResumableError, match="serialized transaction"): + resume(b, to_progress(plan, progress.receipt.replace(protocol_state={"routeId": plan.route_id}))) + with pytest.raises(CheckpointInvalidError, match="id does not match"): + resume(b, to_progress(plan, progress.receipt.replace(id="at1else"))) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + resume(b, to_progress(plan, progress.receipt.replace( + protocol_state={**progress.receipt.protocol_state, "routeId": "other"}))) + assert b.aleo.submitted == [] + + +def test_resume_refuses_a_solana_source_leg_and_points_at_recover(): + """``SolCall`` has no approval step and no resumable pre-broadcast state: there is nothing to + continue, so resume never guesses — it sends the caller to recover()/wait().""" + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id}) + with pytest.raises(NotResumableError, match="recover"): + resume(b, to_progress(plan, receipt)) + assert b.calls == [] + + +# ── resume: EVM source ──────────────────────────────────────────────────────── + +def test_resume_evm_xreserve_rescans_then_deposits_once(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt # history has no deposit → still submission pending + cps = [] + out = resume(b, to_progress(plan, receipt), secret_nonce="7scalar", on_checkpoint=cps.append) + assert [c[0] for c in b.calls] == ["eth.recover_source", "eth.quote_deposit_usdc", "eth.deposit_usdc"] + assert b.calls[0][2] is True # required=True: refuse to guess without an approval block + assert b.calls[1][1] == {"plan": plan, "secret_nonce": "7scalar"} + assert b.calls[2][1] == {"plan": plan, "secret_nonce": "7scalar"} + assert out.receipt.status is Status.ATTESTATION_PENDING + assert out.receipt.protocol_state["approvalTxIds"] == [APPROVAL] # prior approval carried forward + # the secret nonce reaches the module and nothing else + assert "7scalar" not in json.dumps(out.receipt.protocol_state) + assert all("7scalar" not in json.dumps(c.to_dict()) for c in cps) + + +def test_resume_evm_never_repeats_a_deposit_history_already_contains(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt.replace(status=Status.ATTESTATION_PENDING, id="0x" + "cc" * 32) + out = resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert [c[0] for c in b.calls] == ["eth.recover_source"] + assert out.receipt.status is Status.ATTESTATION_PENDING and out.next == "wait" + + +def test_resume_refuses_when_the_re_quoted_hook_data_does_not_match_the_checkpoint(): + """veil guard 1: the hook commits ``(recipient, secret_nonce)``. A deposit built with a + different nonce than the approval was quoted against mints to a commitment nobody can open.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + b.eth.hook_data = bytes([2]) + b"\x99" * 64 + with pytest.raises(NotResumableError, match="secret nonce"): + resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert "eth.deposit_usdc" not in [c[0] for c in b.calls] + + +@pytest.mark.parametrize("hook", [None, "not-hex", "0x", "0x" + "11" * 64, "0x" + "11" * 66, 65]) +def test_resume_refuses_an_xreserve_checkpoint_without_usable_hook_data(hook): + """Fix round 1 (R1): with no checkpointed hook there is nothing to compare the re-quote + against, so the equality guard below would pass vacuously and the deposit could be re-hooked to + a different commitment. Missing, malformed or wrong-width hook data is refused before any RPC — + including the history scan, so nothing is read on a transfer resume() will not finish.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + state = {k: v for k, v in receipt.protocol_state.items() if k != "hookData"} + if hook is not None: + state["hookData"] = hook + b.eth.recover_result = receipt + with pytest.raises(NotResumableError, match="hook data"): + resume(b, to_progress(plan, receipt.replace(protocol_state=state)), secret_nonce="7scalar") + assert b.calls == [] + + +def test_resume_refuses_when_the_recovered_allowance_is_gone(): + """veil guard 2: the approval this checkpoint recorded no longer covers the deposit — something + else spent it. Re-approving here would be a second irreversible step resume never owns.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + b.eth.approval_required = True + with pytest.raises(NotResumableError, match="allowance"): + resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert "eth.deposit_usdc" not in [c[0] for c in b.calls] + + +def test_resume_of_a_private_mint_without_its_secret_nonce_is_refused_before_any_rpc(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + with pytest.raises(ConfigurationError, match="secret_nonce"): + resume(b, to_progress(plan, receipt)) + assert b.calls == [] + # a public mint has nothing to commit to: the default is fine + b2 = FakeBridge() + plan2, receipt2 = _xreserve_progress(b2) + b2.eth.recover_result = receipt2 + assert resume(b2, to_progress(plan2, receipt2)).receipt.status is Status.ATTESTATION_PENDING + assert b2.calls[1][1]["secret_nonce"] == "0scalar" + + +def test_resume_evm_hyperlane_redispatches_without_re_approving(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT) + receipt = Receipt(id=APPROVAL, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": EVM_ADDRESS}) + b.eth.recover_result = receipt + out = resume(b, to_progress(plan, receipt)) + assert [c[0] for c in b.calls] == ["eth.recover_source", "eth.quote_transfer_remote", "eth.transfer_remote"] + assert out.receipt.status is Status.SOURCE_CONFIRMING + assert out.receipt.protocol_state["approvalTxIds"] == [APPROVAL] + b2 = FakeBridge() + b2.eth.recover_result = receipt + b2.eth.approval_required = True + with pytest.raises(NotResumableError, match="allowance"): + resume(b2, to_progress(plan, receipt)) + + +def test_resume_refuses_a_plan_prepared_for_another_account(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b) + stale = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender="0x0000000000000000000000000000000000000009") + with pytest.raises(ConfigurationError, match="sender"): + resume(b, to_progress(stale, receipt.replace(protocol_state={**receipt.protocol_state, + "routeId": stale.route_id}))) + assert b.calls == [] + + +# ── complete ────────────────────────────────────────────────────────────────── + +def _ready(b): + plan, payload, message_hash, receipt = _inbound_private(b) + ready = receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": "aleo-testnet"}, + protocol_state={**receipt.protocol_state, "attestation": SIG}) + return plan, payload, message_hash, ready + + +def test_complete_proves_checkpoints_then_submits_one_private_mint(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + cps = [] + out = complete(b, to_progress(plan, ready), secret_nonce="7scalar", on_checkpoint=cps.append) + assert b.calls[-1][0] == "xreserve.private_mint" and b.calls[-1][1]["secret_nonce"] == "7scalar" + assert [e[0] for e in b.events] == ["delegate_prepared", "checkpoint:DESTINATION_ACTION_REQUIRED", + "submit", "checkpoint:DESTINATION_CONFIRMING"] + serialized = json.dumps({"type": "execute", "id": "at1fake1", "fee": {}}) + assert cps[0].destination == {"preparedTransaction": {"transactionId": "at1fake1", + "serializedTransaction": serialized}} + assert cps[0].source == {"transactionId": "0x" + "22" * 32} + assert cps[1].destination == {"transactionId": "at1fake1"} + assert out.next == "wait" and out.receipt.status is Status.DESTINATION_CONFIRMING + assert out.receipt.destination_tx_id == "at1fake1" and out.receipt.next_action is None + assert out.receipt.protocol_state["payload"] == "0x" + payload.hex() + assert out.receipt.protocol_state["destinationFunction"] == "private_mint" + assert "preparedDestinationTransaction" not in out.receipt.protocol_state + # the nonce, the attestation and the hook never reach the recovery record + for cp in cps: + text = json.dumps(cp.to_dict()) + assert "7scalar" not in text and SIG not in text and "attestation" not in text + assert "7scalar" not in json.dumps(out.receipt.protocol_state) + assert "secretNonce" not in json.dumps(out.receipt.protocol_state) + + +def test_complete_never_writes_a_secret_to_the_bound_checkpoint_store(tmp_path): + """Fix round 1 (R2): the same claim as above, but proved against what actually reaches disk — + every byte the store wrote, not just the Checkpoint objects handed to the callback. The secret + nonce, Circle's attestation and the hook the deposit committed to must appear in none of it.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(environment="testnet", checkpoints=store) + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + written = [] + real_save = store.save + store.save = lambda cp: (written.append(cp.to_json()), real_save(cp))[1] + + complete(b, to_progress(plan, ready), secret_nonce="7scalar") + + hook_hex = payload[-65:].hex() # the commitment the deposit was hooked to + secrets = ["7scalar", "secretNonce", SIG, SIG[2:], hook_hex, payload.hex(), "attestation"] + on_disk = [p.read_text(encoding="utf-8") for p in tmp_path.glob("*.json")] + assert written and on_disk # the store really was exercised + for text in written + on_disk: + for secret in secrets: + assert secret not in text, f"{secret!r} leaked into a checkpoint" + # ...and what IS kept is enough to recover the mint + assert [c.id for c in store.list()] == [message_hash] + assert store.list()[0].destination == {"transactionId": "at1fake1"} + + +def test_complete_never_reaches_proving_with_a_nonce_that_opens_no_commitment(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + with pytest.raises(AttestationError): + complete(b, to_progress(plan, ready), secret_nonce="8scalar") + assert b.events == [] and b.submitted == [] + + +def test_complete_of_a_private_mint_without_its_secret_nonce_is_refused_before_any_rpc(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + with pytest.raises(ConfigurationError, match="secret_nonce"): + complete(b, to_progress(plan, ready)) + assert b.calls == [] and b.events == [] + + +def test_complete_rebroadcasts_a_prepared_destination_without_reproving(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + serialized = json.dumps({"type": "execute", "id": "at1private", "fee": {}}) + ready = ready.replace(id="at1private", + protocol_state={**ready.protocol_state, "preparedDestinationTransaction": serialized}) + cps = [] + out = complete(b, to_progress(plan, ready), on_checkpoint=cps.append) # no secret nonce needed + assert b.aleo.submitted == [serialized] + assert not any(e[0] == "delegate_prepared" for e in b.events) + assert out.receipt.status is Status.DESTINATION_CONFIRMING and out.receipt.destination_tx_id == "at1private" + assert "preparedDestinationTransaction" not in out.receipt.protocol_state + assert len(cps) == 1 and cps[0].destination == {"transactionId": "at1private"} + # the node already knows it: the earlier broadcast won + b.aleo.duplicate_on_submit = True + assert complete(b, to_progress(plan, ready)).receipt.status is Status.DESTINATION_CONFIRMING + # ...but a mismatched id never becomes this transfer's destination transaction + b2 = FakeBridge(environment="testnet") + b2.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1private"): + complete(b2, to_progress(plan, ready)) + + +def test_complete_guards(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + with pytest.raises(NotResumableError, match="complete"): + complete(b, to_progress(plan, ready.replace(status=Status.ATTESTATION_PENDING, next_action=None))) + with pytest.raises(NotResumableError, match="destination action"): + complete(b, to_progress(plan, ready.replace(next_action={"kind": "other", "chainId": "aleo-testnet"}))) + with pytest.raises(AttestationError, match="attestation"): + complete(b, to_progress(plan, ready.replace(protocol_state={**ready.protocol_state, "attestation": "zz"}))) + with pytest.raises(AttestationError): + complete(b, to_progress(plan, ready.replace( + protocol_state={k: v for k, v in ready.protocol_state.items() if k != "payload"}))) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + complete(b, to_progress(plan, ready.replace( + protocol_state={**ready.protocol_state, "routeId": "other"}))) + assert b.aleo.submitted == [] and b.submitted == [] and b.events == [] diff --git a/bridge-sdk/tests/test_sealevel_accounts.py b/bridge-sdk/tests/test_sealevel_accounts.py new file mode 100644 index 0000000..834e4e3 --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_accounts.py @@ -0,0 +1,105 @@ +import dataclasses + +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import RouteUnavailableError +from aleo_bridge.registry import DEFAULT_REGISTRY, Route +from tests.fakes.sealevel_fixtures import TRANSFER, WARP_PROGRAM_ADDRESS, metadata_from_fixture + +ACCOUNTS = TRANSFER["accounts"] + + +def route_with(metadata: dict) -> Route: + base = DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID) + return dataclasses.replace(base, availability="active", metadata=metadata) + + +def test_pdas_match_the_recorded_transaction(): + unique = TRANSFER["uniqueMessageAddress"] + assert sl.derive_dispatched_message_pda(ACCOUNTS[3]["address"], unique) == ACCOUNTS[8]["address"] + assert sl.derive_gas_payment_pda(ACCOUNTS[9]["address"], unique) == ACCOUNTS[11]["address"] + dispatched, bump_dispatched = sl.find_program_address( + [*sl.DISPATCHED_MESSAGE_SEED_PREFIX, sl.b58decode(unique)], ACCOUNTS[3]["address"]) + gas_payment, bump_gas = sl.find_program_address( + [*sl.GAS_PAYMENT_SEED_PREFIX, sl.b58decode(unique)], ACCOUNTS[9]["address"]) + assert (dispatched, bump_dispatched) == (ACCOUNTS[8]["address"], 253) # SEALEVEL_NOTES §2 bump seeds + assert (gas_payment, bump_gas) == (ACCOUNTS[11]["address"], 255) + + +def test_route_static_pdas_recompute_from_the_warp_program(): + # The registry's tokenPda / dispatchAuthorityPda / nativeCollateralPda / outbox / IGP program-data are + # PDAs of the warp, mailbox and IGP programs (SEALEVEL_NOTES §2-3); bumps 255, 254, 255, 255, 254. + assert sl.find_program_address([b"hyperlane_message_recipient", b"-", b"handle", b"-", b"account_metas"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[2]["address"], 255) + assert sl.find_program_address([b"hyperlane_dispatcher", b"-", b"dispatch_authority"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[5]["address"], 254) + assert sl.find_program_address([b"hyperlane_token", b"-", b"native_collateral"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[15]["address"], 255) + assert sl.find_program_address([b"hyperlane", b"-", b"outbox"], ACCOUNTS[3]["address"]) == (ACCOUNTS[4]["address"], 255) + assert sl.find_program_address([b"hyperlane_igp", b"-", b"program_data"], ACCOUNTS[9]["address"]) == (ACCOUNTS[10]["address"], 254) + + +def test_pda_derivation_agrees_with_solders_on_random_keys(): + solders_keypair = pytest.importorskip("solders.keypair") + from solders.pubkey import Pubkey + + mailbox = Pubkey.from_string(ACCOUNTS[3]["address"]) + for _ in range(16): + unique = solders_keypair.Keypair().pubkey() + seeds = [*sl.DISPATCHED_MESSAGE_SEED_PREFIX, bytes(unique)] + expected, expected_bump = Pubkey.find_program_address(seeds, mailbox) + assert sl.find_program_address(seeds, str(mailbox)) == (str(expected), expected_bump) + + +def test_find_program_address_rejects_oversized_seeds(): + with pytest.raises(sl.BridgeError, match="32 bytes"): + sl.find_program_address([bytes(33)], WARP_PROGRAM_ADDRESS) + with pytest.raises(sl.BridgeError, match="16 seeds"): + sl.find_program_address([b"x"] * 17, WARP_PROGRAM_ADDRESS) + + +def test_account_metas_reproduce_the_recorded_16_accounts(): + metadata = sl.solana_route_metadata(route_with(metadata_from_fixture())) + metas = sl.account_metas(metadata, TRANSFER["senderAddress"], TRANSFER["uniqueMessageAddress"]) + assert len(metas) == 16 + assert [m.to_dict() for m in metas] == ACCOUNTS + + +def test_account_metas_omit_the_optional_overhead_slot(): + metadata = sl.solana_route_metadata(route_with(metadata_from_fixture(overhead=False))) + metas = sl.account_metas(metadata, TRANSFER["senderAddress"], TRANSFER["uniqueMessageAddress"]) + overhead = ACCOUNTS[12]["address"] + assert len(metas) == 15 + assert overhead not in [m.address for m in metas] + assert [m.to_dict() for m in metas] == [a for a in ACCOUNTS if a["address"] != overhead] + + +def test_default_registry_route_carries_the_recorded_deployment(): + live = sl.solana_route_metadata(DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID)) + recorded = sl.solana_route_metadata(route_with(metadata_from_fixture())) + for field in ("warp_program_address", "token_pda", "native_collateral_pda", "dispatch_authority_pda", + "mailbox_program_address", "mailbox_outbox_pda", "igp_program_address", "igp_program_data_pda", + "igp_account", "igp_overhead_account", "spl_noop_program_address", "destination_domain", + "destination_gas_amount", "registry_commit"): + assert getattr(live, field) == getattr(recorded, field), field + assert live.destination_gas_amount == 464_000 + assert live.igp_overhead_account == "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF" + + +def test_route_metadata_validation(): + inactive = dataclasses.replace(DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID), availability="metadata-required") + with pytest.raises(RouteUnavailableError, match="not executable"): + sl.solana_route_metadata(inactive) + with pytest.raises(RouteUnavailableError, match="invalid igpAccount"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "igpAccount": "not-a-solana-address"})) + with pytest.raises(RouteUnavailableError, match="invalid destinationDomain"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "destinationDomain": "1634493807"})) + with pytest.raises(RouteUnavailableError, match="invalid destinationGasAmount"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "destinationGasAmount": "lots"})) + with pytest.raises(RouteUnavailableError, match="invalid registryCommit"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "registryCommit": "418056e2"})) + missing = metadata_from_fixture() + del missing["mailboxOutboxPda"] + with pytest.raises(RouteUnavailableError, match="invalid mailboxOutboxPda"): + sl.solana_route_metadata(route_with(missing)) + xreserve = DEFAULT_REGISTRY.route("xreserve:ethereum/usdc->aleo/usdcx") + with pytest.raises(RouteUnavailableError, match="Hyperlane"): + sl.solana_route_metadata(xreserve) diff --git a/bridge-sdk/tests/test_sealevel_igp.py b/bridge-sdk/tests/test_sealevel_igp.py new file mode 100644 index 0000000..ce08dfc --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_igp.py @@ -0,0 +1,71 @@ +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ( + ALEO_MAINNET_DOMAIN, + DESTINATION_GAS_AMOUNT, + EXPECTED_IGP_PAYMENT_LAMPORTS, + IGP, + igp_account_data, +) + +HEADER_NO_OWNER = 1 + 8 + 1 + 32 + 1 + 32 + 4 # initialized, disc, bump, salt, owner=None, beneficiary, count + + +def synthetic_igp(domain: int, *, tag: int = 0, rate: int = 1, price: int = 1, decimals: int = 9) -> bytes: + data = bytearray(HEADER_NO_OWNER + sl.GAS_ORACLE_ENTRY_BYTES) + data[0] = 1 + data[1:9] = sl.IGP_DISCRIMINATOR + data[HEADER_NO_OWNER - 4:HEADER_NO_OWNER] = (1).to_bytes(4, "little") + entry = HEADER_NO_OWNER + data[entry:entry + 4] = domain.to_bytes(4, "little") + data[entry + 4] = tag + data[entry + 5:entry + 21] = rate.to_bytes(16, "little") + data[entry + 21:entry + 37] = price.to_bytes(16, "little") + data[entry + 37] = decimals + return bytes(data) + + +def test_decode_recorded_inner_igp_account(): + account = sl.decode_igp_account(igp_account_data()) + assert account.bump == 255 + assert account.owner is not None and len(account.owner) in (43, 44) + assert len(account.beneficiary) in (43, 44) + assert account.gas_oracles[ALEO_MAINNET_DOMAIN] == sl.GasOracle(751_705_303_136, 83_169, 6) + assert len(account.gas_oracles) + len(account.unsupported_oracles) == 42 + + +def test_quote_reproduces_sealevel_notes_vector(): + assert sl.quote_igp_lamports(igp_account_data(), ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT) == EXPECTED_IGP_PAYMENT_LAMPORTS + assert sl.igp_lamports(sl.GasOracle(751_705_303_136, 83_169, 6), 464_000) == 2_900_000 + + +def test_igp_lamports_divides_when_token_decimals_exceed_nine(): + # dest_cost = 10^6 * 1 ; origin_cost = 10^6 * 10^19 / 10^19 = 10^6 ; decimals 12 → // 10^3 + assert sl.igp_lamports(sl.GasOracle(10 ** 19, 1, 12), 10 ** 6) == 1_000 + assert sl.igp_lamports(sl.GasOracle(10 ** 19, 1, 9), 10 ** 6) == 1_000_000 + + +def test_quote_rejects_missing_domain_and_unknown_variant_tag(): + with pytest.raises(BridgeError, match="no gas-oracle entry for destination domain 999999999"): + sl.quote_igp_lamports(igp_account_data(), 999_999_999, 1) + with pytest.raises(BridgeError, match="unexpected GasOracle variant tag 7"): + sl.quote_igp_lamports(synthetic_igp(42, tag=7), 42, 1) + assert sl.quote_igp_lamports(synthetic_igp(42, rate=10 ** 19, price=5, decimals=9), 42, 3) == 15 + + +def test_decode_rejects_malformed_layouts(): + with pytest.raises(BridgeError, match="not initialized"): + sl.decode_igp_account(bytes(12)) + with pytest.raises(BridgeError, match="declared layout exceeds the supplied bytes"): + sl.decode_igp_account(b"\x01" + sl.IGP_DISCRIMINATOR + bytes(3)) + with pytest.raises(BridgeError, match="discriminator"): + sl.decode_igp_account(b"\x01" + b"WRONGDIS" + bytes(80)) + bad_owner = bytearray(80) + bad_owner[0] = 1 + bad_owner[1:9] = sl.IGP_DISCRIMINATOR + bad_owner[42] = 2 + with pytest.raises(BridgeError, match="owner option tag 2"): + sl.decode_igp_account(bytes(bad_owner)) + assert IGP["address"] == "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M" diff --git a/bridge-sdk/tests/test_sealevel_instruction.py b/bridge-sdk/tests/test_sealevel_instruction.py new file mode 100644 index 0000000..f7542ff --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_instruction.py @@ -0,0 +1,41 @@ +import base64 + +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.encoding import aleo_address_to_bytes32 +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ALEO_MAINNET_DOMAIN, TRANSFER + +RECIPIENT32_HEX = "1c3496991e7c611ced5ee5cd0cdee969c53efc8a5497ae050819b1ef00ed2912" + + +def test_instruction_data_matches_mainnet_fixture_byte_for_byte(): + data = sl.build_transfer_remote_instruction_data( + ALEO_MAINNET_DOMAIN, + aleo_address_to_bytes32(TRANSFER["recipientAleoAddress"]), + TRANSFER["amountLamports"], + ) + assert len(data) == sl.INSTRUCTION_DATA_BYTES == 77 + assert base64.b64encode(data).decode() == TRANSFER["instructionDataBase64"] + + +def test_instruction_data_layout_offsets(): + data = sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes.fromhex(RECIPIENT32_HEX), 676_200_000_000) + assert data[0:8] == bytes([1] * 8) == sl.PROGRAM_INSTRUCTION_DISCRIMINATOR + assert data[8] == sl.TRANSFER_REMOTE_VARIANT_TAG == 1 + assert data[9:13] == bytes.fromhex("6f656c61") # 0x616c656f little-endian + assert data[13:45] == bytes.fromhex(RECIPIENT32_HEX) # raw bech32m payload, no reversal + assert data[45:77] == (676_200_000_000).to_bytes(32, "little") + assert data[45:49] == bytes.fromhex("002aa970") + + +def test_instruction_data_rejects_bad_inputs(): + with pytest.raises(BridgeError, match="32 bytes"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(31), 1) + with pytest.raises(BridgeError, match="32-byte unsigned"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(32), 1 << 256) + with pytest.raises(BridgeError, match="32-byte unsigned"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(32), -1) + with pytest.raises(BridgeError, match="destination domain"): + sl.build_transfer_remote_instruction_data(1 << 32, bytes(32), 1) diff --git a/bridge-sdk/tests/test_sol_bridge.py b/bridge-sdk/tests/test_sol_bridge.py new file mode 100644 index 0000000..38c9ac6 --- /dev/null +++ b/bridge-sdk/tests/test_sol_bridge.py @@ -0,0 +1,119 @@ +"""Task 8: wiring the Solana connection into Bridge (sol property, from_env, status).""" +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair + +import aleo_bridge +from aleo_bridge import Bridge, Solana +from aleo_bridge._base58 import b58encode +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.sol import SolModule +from aleo_bridge.types import ChainStatus +from tests.conftest import FakeAleo +from tests.fakes.fake_solana import FakeSolanaClient + + +def test_sol_property_raises_when_solana_is_not_configured(): + bridge = Bridge(FakeAleo()) + assert bridge.solana is None + with pytest.raises(ConfigurationError, match="Solana is not configured"): + bridge.sol + + +def test_bare_client_is_wrapped_read_only(): + fake = FakeSolanaClient(balance=5) + bridge = Bridge(FakeAleo(), solana=fake) + assert isinstance(bridge.solana, Solana) and bridge.solana.client is fake + assert bridge.solana.can_sign is False and bridge.solana.address is None + assert isinstance(bridge.sol, SolModule) and bridge.sol is bridge.sol + with pytest.raises(ConfigurationError, match="read-only"): + bridge.sol.balance() + solana_status = [c for c in bridge.status().chains if c.chain_id == "solana"] + assert solana_status == [ChainStatus(chain_id="solana", address=None, can_sign=False, balances={})] + + +def test_configured_connection_reports_sol_balance_in_status(): + keypair = Keypair() + fake = FakeSolanaClient(balance=1_234) + bridge = Bridge(FakeAleo(), solana=Solana(client=fake, signer=keypair)) + assert bridge.solana.address == str(keypair.pubkey()) + solana_status = [c for c in bridge.status().chains if c.chain_id == "solana"][0] + assert solana_status.address == str(keypair.pubkey()) and solana_status.can_sign is True + assert solana_status.balances == {"solana/sol": 1_234} + + +def test_an_rpc_url_string_becomes_a_read_only_connection(): + bridge = Bridge(FakeAleo(), solana="https://rpc.example") + assert isinstance(bridge.solana, Solana) and bridge.solana.rpc_url == "https://rpc.example" + assert bridge.solana.can_sign is False + + +def test_an_object_that_is_not_a_solana_client_is_refused(): + class NotAClient: + def get_latest_blockhash(self, commitment=None): # pragma: no cover - never called + return None + + with pytest.raises(ConfigurationError, match="solana="): + Bridge(FakeAleo(), solana=NotAClient()) # no get_account_info + with pytest.raises(ConfigurationError, match="solana="): + Bridge(FakeAleo(), solana=object()) + + +def test_status_omits_the_solana_row_when_the_environment_has_no_solana_chain(): + """Only mainnet has a Solana chain; a testnet client must not invent a "solana" row.""" + bridge = Bridge(FakeAleo(network_name="testnet"), solana=FakeSolanaClient()) + assert [c.chain_id for c in bridge.status().chains if c.chain_id == "solana"] == [] + + +def test_status_derives_the_solana_chain_and_native_asset_from_the_registry(): + keypair = Keypair() + bridge = Bridge(FakeAleo(), solana=Solana(client=FakeSolanaClient(balance=7), signer=keypair)) + chain = [c for c in bridge.registry.chains(environment="mainnet") if c.family == "solana"][0] + native = [a for a in bridge.registry.assets(chain=chain.id) if a.kind == "native"][0] + row = [c for c in bridge.status().chains if c.chain_id == chain.id][0] + assert row.balances == {native.id: 7} + + +def test_from_env_builds_the_solana_connection(monkeypatch): + key = b58encode(bytes(Keypair())) + seen = {} + + def capture_init(self, aleo, *, ethereum=None, solana=None, environment=None, registry=None, checkpoints=None): + seen["solana"] = solana + + monkeypatch.setattr(Bridge, "__init__", capture_init) + monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *args, **kwargs: FakeAleo()) + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH") + monkeypatch.setenv("SOLANA_PRIVATE_KEY", key) + monkeypatch.setenv("SOLANA_RPC_URL", "https://rpc.example") + Bridge.from_env() + assert isinstance(seen["solana"], Solana) and seen["solana"].can_sign and seen["solana"].rpc_url == "https://rpc.example" + override = Solana(client=FakeSolanaClient()) + Bridge.from_env(solana=override) + assert seen["solana"] is override + + +def test_package_exports(): + assert aleo_bridge.Solana is Solana + assert aleo_bridge.SolModule is SolModule + assert aleo_bridge.DEFAULT_SOLANA_RPC_URL == "https://api.mainnet-beta.solana.com" + from aleo_bridge._calls import AleoCall, EvmCall, SolCall + from aleo_bridge.eth import EthModule + from aleo_bridge.freezelist import FreezeList + from aleo_bridge.hyperlane import HyperlaneModule + from aleo_bridge.profile import Profile + from aleo_bridge.xreserve import XReserveModule + + assert aleo_bridge.SolCall is SolCall + # pre-existing exports (plans 1/2/4) must still be exported after this task's __init__.py edit + assert aleo_bridge.EthModule is EthModule + assert aleo_bridge.HyperlaneModule is HyperlaneModule + assert aleo_bridge.XReserveModule is XReserveModule + assert aleo_bridge.AleoCall is AleoCall + assert aleo_bridge.EvmCall is EvmCall + assert aleo_bridge.FreezeList is FreezeList + assert aleo_bridge.Profile is Profile diff --git a/bridge-sdk/tests/test_sol_connection.py b/bridge-sdk/tests/test_sol_connection.py new file mode 100644 index 0000000..ae503ff --- /dev/null +++ b/bridge-sdk/tests/test_sol_connection.py @@ -0,0 +1,325 @@ +import importlib +import json +import sys + +import pytest + +from aleo_bridge import sol +from aleo_bridge._base58 import b58encode +from aleo_bridge.errors import ConfigurationError, MissingExtraError + + +class _Reader: + """Stands in for a caller-configured solana-py Client; never called in this file.""" + + +def _block_solders_and_solana(monkeypatch): + """Block ``solders``/``solana`` for the duration of the test, reverted automatically by + ``monkeypatch``. ``_libs()`` imports submodules (``from solders.hash import Hash``, ...), and + once any earlier test in the run has imported those submodules for real, Python's import + machinery resolves them straight from ``sys.modules`` without re-checking the (now ``None``) + top-level package — so a plain ``sys.modules["solders"] = None`` only blocks a *first* import. + Purging every already-cached ``solders``/``solana`` submodule first makes the block work + regardless of test order.""" + for name in list(sys.modules): + if name == "solders" or name.startswith("solders.") or name == "solana" or name.startswith("solana."): + monkeypatch.delitem(sys.modules, name, raising=False) + monkeypatch.setitem(sys.modules, "solders", None) + monkeypatch.setitem(sys.modules, "solana", None) + + +def test_package_imports_without_solders_or_solana(monkeypatch): + """``import aleo_bridge`` and ``from aleo_bridge import Solana`` must work with solders/solana + absent; only the first call that actually needs them raises MissingExtraError. Mirrors + test_import_without_web3.py's monkeypatch-and-revert pattern so the blocked modules and the + reimported aleo_bridge never leak into tests that run after this one.""" + _block_solders_and_solana(monkeypatch) + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + monkeypatch.delitem(sys.modules, name) + + pkg = importlib.import_module("aleo_bridge") + assert pkg.Solana is not None + + from aleo_bridge.errors import MissingExtraError as ReimportedMissingExtraError + + with pytest.raises(ReimportedMissingExtraError) as exc_info: + pkg.Solana() + assert "aleo-bridge-sdk[solana]" in str(exc_info.value) + + +def test_read_only_connection_needs_no_solana_extra(monkeypatch): + monkeypatch.setattr(sol, "_LIBS", None) + _block_solders_and_solana(monkeypatch) + conn = sol.Solana(client=_Reader()) + assert conn.address is None and conn.can_sign is False and conn.rpc_url is None + with pytest.raises(ConfigurationError, match="read-only"): + conn.sign_message(b"payload") + with pytest.raises(ConfigurationError, match="read-only"): + conn.pubkey + with pytest.raises(MissingExtraError, match=r"aleo-bridge-sdk\[solana\]"): + sol.Solana() + with pytest.raises(MissingExtraError): + sol.Solana(client=_Reader(), private_key="[1,2,3]") + + +def test_constructor_argument_conflicts(): + with pytest.raises(ConfigurationError, match="rpc_url or client"): + sol.Solana("https://rpc.example", client=_Reader()) + pytest.importorskip("solders") + from solders.keypair import Keypair + + with pytest.raises(ConfigurationError, match="signer or private_key"): + sol.Solana(client=_Reader(), signer=Keypair(), private_key=b58encode(bytes(Keypair()))) + + +def test_default_transport_is_mainnet_beta_at_confirmed_commitment(): + pytest.importorskip("solders") + + conn = sol.Solana() + assert sol.DEFAULT_SOLANA_RPC_URL == "https://api.mainnet-beta.solana.com" + assert conn.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + assert isinstance(conn.client, sol.SolanaRpcClient) + assert conn.client.url == sol.DEFAULT_SOLANA_RPC_URL and conn.client.commitment == "confirmed" + custom = sol.Solana("https://rpc.example") + assert custom.rpc_url == "https://rpc.example" and custom.client.url == "https://rpc.example" + + +def test_async_solana_py_client_is_adapted_onto_a_private_loop(): + pytest.importorskip("solders") + from solders.hash import Hash + + class FakeProvider: + async def make_request(self, request, response_type): + return sol.RpcResult(True) + + class FakeAsyncClient: # the shape of solana-py ≥ 0.36 AsyncClient + _provider = FakeProvider() + + def __init__(self): + self.sent = [] + + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + async def send_raw_transaction(self, txn, opts=None): + self.sent.append((bytes(txn), opts)) + return sol.RpcResult("sig") + + fake = FakeAsyncClient() + conn = sol.Solana(client=fake) + assert isinstance(conn.client, sol._AsyncClientAdapter) and conn.can_sign is False + assert conn.client.get_balance(None).value == 7 # coroutine run synchronously + assert conn.client.is_blockhash_valid(Hash.default()).value is True # supplied by the adapter + pytest.importorskip("solana") + assert conn.client.send_raw_transaction(b"\x01").value == "sig" + sent_tx, tx_opts = fake.sent[0] + assert sent_tx == b"\x01" and tx_opts.skip_preflight is False and tx_opts.skip_confirmation is True + assert str(tx_opts.preflight_commitment) == "confirmed" + + +def test_private_key_forms(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + keypair = Keypair() + secret = bytes(keypair) # 64 bytes: seed || pubkey + assert len(secret) == 64 + for private_key in (b58encode(secret), json.dumps(list(secret)), " " + json.dumps(list(secret)) + "\n", secret): + conn = sol.Solana(client=_Reader(), private_key=private_key) + assert conn.address == str(keypair.pubkey()) and conn.can_sign is True + assert conn.pubkey == keypair.pubkey() + assert sol.keypair_from_private_key(secret[:32]).pubkey() == keypair.pubkey() # 32-byte seed + for bad in ("not-base58-0OIl", "[1, 2, 3]", "[1, 2, \"x\"]", "[", bytes(63)): + with pytest.raises(ConfigurationError): + sol.keypair_from_private_key(bad) + + +def test_keypair_signer_signs_the_message(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + keypair = Keypair() + conn = sol.Solana(client=_Reader(), signer=keypair) + assert conn.address == str(keypair.pubkey()) + assert conn.sign_message(b"payload") == keypair.sign_message(b"payload") + + +def test_any_object_with_pubkey_and_sign_message_is_a_signer(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + inner = Keypair() + + class RemoteSigner: # e.g. HSM / remote signing service + def pubkey(self): + return inner.pubkey() + + def sign_message(self, message: bytes): + return inner.sign_message(message) + + conn = sol.Solana(client=_Reader(), signer=RemoteSigner()) + assert isinstance(RemoteSigner(), sol.SolanaSigner) + assert conn.can_sign and conn.address == str(inner.pubkey()) + assert conn.sign_message(b"m") == inner.sign_message(b"m") + with pytest.raises(ConfigurationError, match="pubkey\\(\\) and sign_message"): + sol.Solana(client=_Reader(), signer=object()) + + +def test_from_env(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + key = b58encode(bytes(Keypair())) + assert sol.Solana.from_env({}) is None + with_key = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key}) + assert with_key is not None and with_key.can_sign and with_key.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + with_url = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key, "SOLANA_RPC_URL": "https://rpc.example"}) + assert with_url is not None and with_url.rpc_url == "https://rpc.example" + read_only = sol.Solana.from_env({"SOLANA_RPC_URL": "https://rpc.example"}) + assert read_only is not None and read_only.can_sign is False + + +def test_async_client_adapter_close_stops_the_thread(): + pytest.importorskip("solders") + + class FakeAsyncClient: + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + adapter = sol._AsyncClientAdapter(FakeAsyncClient()) + assert adapter._thread.is_alive() + adapter.close() + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: no error, no hang + + +def test_solana_context_manager_closes_the_wrapped_adapter(): + pytest.importorskip("solders") + + class FakeAsyncClient: + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + fake = FakeAsyncClient() + with sol.Solana(client=fake) as conn: + adapter = conn.client + assert isinstance(adapter, sol._AsyncClientAdapter) + assert adapter._thread.is_alive() + assert adapter._thread.is_alive() is False + + +def test_adapter_close_awaits_the_wrapped_clients_own_close_on_its_loop(): + """solana-py's AsyncClient owns an aiohttp session that can only be closed from its loop; + stopping the thread first would leak it (and warn).""" + pytest.importorskip("solders") + + class FakeAsyncClient: + def __init__(self): + self.closed_on = None + + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + async def close(self): + import threading as _threading + self.closed_on = _threading.current_thread().name + + fake = FakeAsyncClient() + adapter = sol._AsyncClientAdapter(fake) + loop_thread = adapter._thread.name + adapter.close() + assert fake.closed_on == loop_thread # awaited on the private loop, before it stopped + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: closes the client exactly once + + +def test_adapter_close_still_stops_the_thread_when_the_wrapped_client_close_raises(): + """A third-party client that fails to close must not leak our private event-loop thread.""" + pytest.importorskip("solders") + + class ExplodingClient: + def __init__(self): + self.attempts = 0 + + async def get_balance(self, pubkey, commitment=None): # pragma: no cover - never called + return sol.RpcResult(7) + + def close(self): + self.attempts += 1 + raise OSError("socket already gone") + + fake = ExplodingClient() + adapter = sol._AsyncClientAdapter(fake) + adapter.close() + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: no second close attempt + assert fake.attempts == 1 + + +def test_adapter_close_still_stops_the_thread_when_an_async_client_close_raises(): + pytest.importorskip("solders") + + class ExplodingAsyncClient: + async def get_balance(self, pubkey, commitment=None): # pragma: no cover - never called + return sol.RpcResult(7) + + async def close(self): + raise OSError("session already detached") + + adapter = sol._AsyncClientAdapter(ExplodingAsyncClient()) + adapter.close() + assert adapter._thread.is_alive() is False + + +def test_solana_exit_never_raises_even_when_the_client_close_fails(): + pytest.importorskip("solders") + + class ExplodingClient: + def get_latest_blockhash(self, commitment=None): # pragma: no cover - never called + return None + + def close(self): + raise OSError("socket already gone") + + with sol.Solana(client=ExplodingClient()) as conn: + assert conn.client is not None + with pytest.raises(OSError): # an explicit close() still reports it + sol.Solana(client=ExplodingClient()).close() + + +def test_rpc_client_close_closes_its_session(): + pytest.importorskip("solders") + + class FakeSession: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + session = FakeSession() + conn = sol.Solana(client=sol.SolanaRpcClient("https://rpc.example", session=session)) + conn.close() + assert session.closed == 1 # the default transport releases its pool + + +def test_close_is_a_noop_for_a_connection_without_a_closeable_client(): + conn = sol.Solana(client=_Reader()) + conn.close() # no close() on the client — must not raise + conn.close() + + +def test_from_env_aliases(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + key = b58encode(bytes(Keypair())) + aliased = sol.Solana.from_env({"BRIDGE_SOLANA_PRIVATE_KEY": key}) + assert aliased is not None and aliased.can_sign and aliased.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + aliased_url = sol.Solana.from_env({"BRIDGE_SOLANA_PRIVATE_KEY": key, "BRIDGE_LIVE_SOLANA_RPC_URL": "https://rpc.example"}) + assert aliased_url is not None and aliased_url.rpc_url == "https://rpc.example" + primary_wins = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key, "BRIDGE_SOLANA_PRIVATE_KEY": "ignored", + "SOLANA_RPC_URL": "https://primary.example", + "BRIDGE_LIVE_SOLANA_RPC_URL": "https://alias.example"}) + assert primary_wins is not None and primary_wins.rpc_url == "https://primary.example" diff --git a/bridge-sdk/tests/test_sol_quote.py b/bridge-sdk/tests/test_sol_quote.py new file mode 100644 index 0000000..4f65838 --- /dev/null +++ b/bridge-sdk/tests/test_sol_quote.py @@ -0,0 +1,165 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import ( + BridgeError, + ConfigurationError, + InvalidAmountError, + InvalidRecipientError, + RegistryVersionMismatchError, + RouteNotFoundError, +) +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import SolanaHyperlaneQuote +from tests.fakes.fake_solana import FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import ( + DISPATCHED_MESSAGE_RENT_LAMPORTS, + EXPECTED_IGP_PAYMENT_LAMPORTS, + FEE_PAYER_RENT_LAMPORTS, + GAS_PAYMENT_RENT_LAMPORTS, + NETWORK_FEE_LAMPORTS, + TRANSFER, + WARP_PROGRAM_ADDRESS, +) + +RECIPIENT = TRANSFER["recipientAleoAddress"] +SENDER = TRANSFER["senderAddress"] +AMOUNT = TRANSFER["amountLamports"] +RENT = GAS_PAYMENT_RENT_LAMPORTS + DISPATCHED_MESSAGE_RENT_LAMPORTS + FEE_PAYER_RENT_LAMPORTS + + +def module(fake=None, *, signer=None, environment="mainnet") -> tuple[SolModule, FakeSolanaClient]: + fake = fake or FakeSolanaClient() + return SolModule(stub_bridge(environment), Solana(client=fake, signer=signer)), fake + + +def test_quote_with_pinned_sender_on_read_only_connection(): + mod, fake = module() + quote = mod.quote_transfer_remote(RECIPIENT, amount="676.2", sender=SENDER) + assert isinstance(quote, SolanaHyperlaneQuote) and quote.kind == "solana-hyperlane" + assert quote.igp_lamports == EXPECTED_IGP_PAYMENT_LAMPORTS + assert quote.network_fee_lamports == NETWORK_FEE_LAMPORTS + assert quote.rent_lamports == RENT == 5_004_240 + assert quote.total_lamports == AMOUNT + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT == 676_207_914_240 + assert quote.plan.route_id == sl.SOLANA_ROUTE_ID + assert quote.plan.sender == SENDER and quote.plan.recipient == RECIPIENT + assert quote.plan.amount == "676.2" and quote.plan.amount_atomic == AMOUNT + assert quote.plan.mint_mode == "public" and [s.id for s in quote.plan.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert quote.amount_out == "676.2" + assert sl.SOLANA_PUBKEY_RE.match(quote.unique_message_address) + assert [(f.kind, f.amount) for f in quote.fees] == [("interchain-gas", "0.0029"), ("network", "0.00001"), ("rent", "0.00500424")] + assert "send_raw_transaction" not in fake.calls and "get_balance" not in fake.calls + + +def test_quote_fee_message_is_a_v0_message_with_compute_budget_and_transfer_remote(): + mod, fake = module(signer=Keypair()) + quote = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1) + assert quote.plan.sender == mod.conn.address + message = fake.fee_messages[0] + assert message.header.num_required_signatures == 2 + assert message.header.num_readonly_signed_accounts == 1 + programs = [str(message.account_keys[ix.program_id_index]) for ix in message.instructions] + assert programs == ["ComputeBudget111111111111111111111111111111", WARP_PROGRAM_ADDRESS] + assert bytes(message.instructions[0].data) == bytes.fromhex("02801a0600") # SetComputeUnitLimit(400_000) + assert len(bytes(message.instructions[1].data)) == 77 + assert str(message.recent_blockhash) == WARP_PROGRAM_ADDRESS + assert fake.calls.count("get_minimum_balance_for_rent_exemption") == 3 + + +def test_quote_uses_amount_atomic_and_rejects_ambiguous_amounts(): + mod, _ = module() + assert mod.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER).plan.amount == "0.000000001" + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, sender=SENDER) + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, amount="1", amount_atomic=1, sender=SENDER) + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, amount_atomic=0, sender=SENDER) + + +def test_quote_error_paths(): + mod, _ = module() + with pytest.raises(ConfigurationError, match="sender"): + mod.quote_transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(InvalidRecipientError): + mod.quote_transfer_remote("aleo1notanaddress", amount_atomic=1, sender=SENDER) + missing_igp, _ = module(FakeSolanaClient(accounts={})) + with pytest.raises(BridgeError, match="IGP account does not exist"): + missing_igp.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER) + testnet, _ = module(environment="testnet") + with pytest.raises(RouteNotFoundError): + testnet.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER) + + +def test_quote_with_plan_checks_registry_version_and_reuses_the_plan(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + quoted = mod.quote_transfer_remote(RECIPIENT, plan=plan) + assert quoted.plan is plan and quoted.total_lamports == 5 + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT + stale = dataclasses.replace(plan, registry_version="2020-01-01.stale.0") + with pytest.raises(RegistryVersionMismatchError): + mod.quote_transfer_remote(RECIPIENT, plan=stale) + + +def test_a_plan_alone_supplies_recipient_amount_and_sender(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + quoted = mod.quote_transfer_remote(plan=plan) # no positional recipient + assert quoted.plan is plan and quoted.plan.sender == SENDER + assert quoted.total_lamports == 5 + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT + + +def test_a_plan_is_mutually_exclusive_with_sender_and_a_differing_amount(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + with pytest.raises(ValueError, match="plan"): + mod.quote_transfer_remote(plan=plan, sender=SENDER) + with pytest.raises(ValueError, match="plan"): + mod.quote_transfer_remote(plan=plan, amount_atomic=6) + assert mod.quote_transfer_remote(plan=plan, amount_atomic=5).plan is plan # identical is fine + + +def test_neither_a_plan_nor_a_recipient_is_refused(): + mod, fake = module() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + mod.quote_transfer_remote(amount_atomic=1, sender=SENDER) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + mod.transfer_remote(amount_atomic=1) + assert fake.sent == [] + + +def test_a_malformed_json_private_key_never_carries_the_secret_into_the_traceback(): + """A chained JSONDecodeError keeps the whole document in .doc — which IS the private key.""" + from aleo_bridge.sol import keypair_from_private_key + + secret = "[17,42,99,128" # a truncated solana-cli id.json + with pytest.raises(ConfigurationError) as excinfo: + keypair_from_private_key(secret) + exc = excinfo.value + assert exc.__cause__ is None and exc.__context__ is None + assert "17" not in repr(exc) and secret not in repr(exc) + + +def test_a_malformed_base58_private_key_never_carries_the_secret_into_the_traceback(): + from aleo_bridge.sol import keypair_from_private_key + + secret = "5JueXBoJHvOoPeKeYsEcReT" + with pytest.raises(ConfigurationError) as excinfo: + keypair_from_private_key(secret) + exc = excinfo.value + assert exc.__cause__ is None and exc.__context__ is None + assert secret not in repr(exc) + + +def test_balance_reads_the_connected_wallet(): + keypair = Keypair() + mod, fake = module(FakeSolanaClient(balance=42), signer=keypair) + assert mod.balance() == 42 + read_only, _ = module() + with pytest.raises(ConfigurationError, match="read-only"): + read_only.balance() diff --git a/bridge-sdk/tests/test_sol_rpc.py b/bridge-sdk/tests/test_sol_rpc.py new file mode 100644 index 0000000..a0eb8c4 --- /dev/null +++ b/bridge-sdk/tests/test_sol_rpc.py @@ -0,0 +1,186 @@ +import base64 +import json + +import pytest + +pytest.importorskip("solders") +from solders.hash import Hash +from solders.keypair import Keypair +from solders.message import MessageV0, to_bytes_versioned +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.compute_budget import set_compute_unit_limit + +from aleo_bridge import sol +from aleo_bridge.errors import BridgeError + +SIG = str(Signature.from_bytes(bytes([9]) * 64)) +ADDR = "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC" +HASH = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + + +class FakeResponse: + def __init__(self, body, status_code=200, invalid_json=False): + self._body, self.status_code, self._invalid = body, status_code, invalid_json + + def json(self): + if self._invalid: + raise ValueError("bad json") + return self._body + + +class FakeSession: + """Answers each POST from a queue of bodies (the last repeats) and records every request.""" + + def __init__(self, *bodies, status_code=200, invalid_json=False): + self.bodies = list(bodies) or [{"jsonrpc": "2.0", "id": 1, "result": None}] + self.status_code, self.invalid_json = status_code, invalid_json + self.calls: list[dict] = [] + + def post(self, url, *, json, timeout, headers): + self.calls.append({"url": url, "json": json, "timeout": timeout, "headers": headers}) + body = self.bodies.pop(0) if len(self.bodies) > 1 else self.bodies[0] + return FakeResponse(body, self.status_code, self.invalid_json) + + def last(self): + return self.calls[-1]["json"]["method"], self.calls[-1]["json"]["params"] + + +def ok(result): + return {"jsonrpc": "2.0", "id": 1, "result": result} + + +def ctx(value): + return ok({"context": {"slot": 1}, "value": value}) + + +def client(*bodies, **kw): + session = FakeSession(*bodies, **kw) + return sol.SolanaRpcClient("http://rpc.test", session=session), session + + +def test_request_envelope_and_defaults(): + rpc, session = client(ok(42)) + assert rpc.get_block_height().value == 42 + call = session.calls[0] + assert call["url"] == "http://rpc.test" and call["timeout"] == 30.0 + assert call["json"]["jsonrpc"] == "2.0" and call["headers"]["cache-control"] == "no-cache" + assert session.last() == ("getBlockHeight", [{"commitment": "confirmed"}]) + assert rpc.commitment == "confirmed" and rpc.url == "http://rpc.test" + + +def test_latest_blockhash(): + rpc, session = client(ctx({"blockhash": HASH, "lastValidBlockHeight": 123456789})) + value = rpc.get_latest_blockhash().value + assert value.blockhash == Hash.from_string(HASH) and value.last_valid_block_height == 123456789 + assert session.last() == ("getLatestBlockhash", [{"commitment": "confirmed"}]) + rpc2, _ = client(ctx({"blockhash": "", "lastValidBlockHeight": 1})) + with pytest.raises(BridgeError, match="getLatestBlockhash returned an invalid result"): + rpc2.get_latest_blockhash() + + +def test_balance_and_rent_and_blockhash_validity(): + rpc, session = client(ctx(1_000_000_000)) + assert rpc.get_balance(Pubkey.from_string(ADDR)).value == 1_000_000_000 + assert session.last() == ("getBalance", [ADDR, {"commitment": "confirmed"}]) + rpc, session = client(ok(890_880)) + assert rpc.get_minimum_balance_for_rent_exemption(0).value == 890_880 + assert session.last() == ("getMinimumBalanceForRentExemption", [0, {"commitment": "confirmed"}]) + with pytest.raises(BridgeError, match="non-negative integer"): + rpc.get_minimum_balance_for_rent_exemption(-1) + rpc, session = client(ctx(True)) + assert rpc.is_blockhash_valid(Hash.from_string(HASH)).value is True + assert session.last() == ("isBlockhashValid", [HASH, {"commitment": "confirmed"}]) + rpc, _ = client(ctx(-1)) + with pytest.raises(BridgeError, match="getBalance returned an invalid result"): + rpc.get_balance(Pubkey.from_string(ADDR)) + + +def test_account_info(): + encoded = base64.b64encode(bytes([1, 2, 3, 4])).decode() + rpc, session = client(ctx({"data": [encoded, "base64"], "owner": ADDR, "lamports": 1, "executable": False, "rentEpoch": 0})) + account = rpc.get_account_info(Pubkey.from_string(ADDR)).value + assert account.data == bytes([1, 2, 3, 4]) and account.lamports == 1 and account.owner == ADDR + assert session.last() == ("getAccountInfo", [ADDR, {"encoding": "base64", "commitment": "confirmed"}]) + rpc, _ = client(ctx(None)) + assert rpc.get_account_info(Pubkey.from_string(ADDR)).value is None + rpc, _ = client(ctx({"data": ["abc", "base58"], "owner": ADDR, "lamports": 1})) + with pytest.raises(BridgeError, match="invalid base64 account data"): + rpc.get_account_info(Pubkey.from_string(ADDR)) + + +def test_fee_for_message_sends_the_versioned_message_bytes(): + payer = Keypair() + message = MessageV0.try_compile(payer.pubkey(), [set_compute_unit_limit(400_000)], [], Hash.from_string(HASH)) + rpc, session = client(ctx(10_000)) + assert rpc.get_fee_for_message(message).value == 10_000 + method, params = session.last() + assert method == "getFeeForMessage" + assert params == [base64.b64encode(to_bytes_versioned(message)).decode(), {"commitment": "confirmed"}] + rpc, _ = client(ctx(None)) + assert rpc.get_fee_for_message(message).value is None + + +def test_send_raw_transaction_params_and_preflight_error_details(): + rpc, session = client(ok(SIG)) + assert rpc.send_raw_transaction(b"\x01\x02\x03").value == Signature.from_string(SIG) + method, params = session.last() + assert method == "sendTransaction" + assert params == [base64.b64encode(b"\x01\x02\x03").decode(), {"encoding": "base64", "skipPreflight": False, "preflightCommitment": "confirmed"}] + rpc, session = client(ok(SIG)) + rpc.send_raw_transaction(b"\x01", sol.SendOptions(skip_preflight=True, preflight_commitment="processed")) + assert session.last()[1][1] == {"encoding": "base64", "skipPreflight": True, "preflightCommitment": "processed"} + failing = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32002, "message": "Transaction simulation failed", + "data": {"err": {"InstructionError": [0, "Custom"]}, "logs": ["Program log: insufficient lamports"]}}} + rpc, _ = client(failing) + with pytest.raises(BridgeError, match="insufficient lamports"): + rpc.send_raw_transaction(b"\x01") + + +def test_signature_statuses(): + rpc, session = client(ctx([None])) + assert rpc.get_signature_statuses([Signature.from_string(SIG)], search_transaction_history=True).value == [None] + assert session.last() == ("getSignatureStatuses", [[SIG], {"searchTransactionHistory": True}]) + rpc, _ = client(ctx([{"err": {"InstructionError": [0, "Custom"]}, "confirmationStatus": "processed", "slot": 1, "confirmations": None}])) + status = rpc.get_signature_statuses([Signature.from_string(SIG)]).value[0] + assert status.err == {"InstructionError": [0, "Custom"]} and status.confirmation_status == "processed" + rpc, _ = client(ctx([{"err": None, "confirmationStatus": "finalized", "slot": 1, "confirmations": None}])) + assert rpc.get_signature_statuses([Signature.from_string(SIG)]).value[0].confirmation_status == "finalized" + rpc, _ = client(ctx([{"err": None, "confirmationStatus": "mystery", "slot": 1}])) + with pytest.raises(BridgeError, match="unsupported confirmation status"): + rpc.get_signature_statuses([Signature.from_string(SIG)]) + rpc, _ = client(ctx([{}])) + with pytest.raises(BridgeError, match="invalid status"): + rpc.get_signature_statuses([Signature.from_string(SIG)]) + + +def test_get_transaction(): + rpc, session = client(ok({"slot": 5, "meta": {"logMessages": ["Program log: hi"]}, "transaction": {}})) + value = rpc.get_transaction(Signature.from_string(SIG), max_supported_transaction_version=0).value + assert value.slot == 5 and value.transaction.meta.log_messages == ["Program log: hi"] + assert session.last() == ("getTransaction", [SIG, {"encoding": "json", "commitment": "confirmed", "maxSupportedTransactionVersion": 0}]) + rpc, _ = client(ok(None)) + assert rpc.get_transaction(Signature.from_string(SIG)).value is None + rpc, _ = client(ok({"slot": 5, "meta": None, "transaction": {}})) + assert rpc.get_transaction(Signature.from_string(SIG)).value.transaction.meta is None + rpc, _ = client(ok({"slot": 5, "meta": {"logMessages": "not-a-list"}})) + with pytest.raises(BridgeError, match="invalid logs"): + rpc.get_transaction(Signature.from_string(SIG)) + + +def test_transport_errors_are_bridge_errors(): + rpc, _ = client({}, status_code=500) + with pytest.raises(BridgeError, match="500"): + rpc.get_block_height() + rpc, _ = client({"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Invalid param"}}) + with pytest.raises(BridgeError, match="Invalid param"): + rpc.get_block_height() + rpc, _ = client({}, invalid_json=True) + with pytest.raises(BridgeError, match="invalid JSON"): + rpc.get_block_height() + rpc, _ = client({"jsonrpc": "2.0", "id": 1}) + with pytest.raises(BridgeError, match="invalid result envelope"): + rpc.get_block_height() + rpc, _ = client(ok("1")) + with pytest.raises(BridgeError, match="invalid result"): + rpc.get_block_height() diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py new file mode 100644 index 0000000..69bb739 --- /dev/null +++ b/bridge-sdk/tests/test_sol_send.py @@ -0,0 +1,433 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair +from solders.message import to_bytes_versioned +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.transaction import VersionedTransaction + +from aleo_bridge import _sealevel as sl +from aleo_bridge import sol +from aleo_bridge._calls import SolCall +from aleo_bridge.errors import ( + BridgeError, + ConfigurationError, + InsufficientBalanceError, + RegistryVersionMismatchError, + UnsupportedRouteError, +) +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import DispatchReceipt, Status +from tests.fakes.fake_solana import BLOCKHASH, STUB_SIGNATURE, FakeSignatureStatus, FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import EXPECTED_MESSAGE_ID, TRANSFER, WARP_PROGRAM_ADDRESS + +RECIPIENT = TRANSFER["recipientAleoAddress"] +AMOUNT = TRANSFER["amountLamports"] +CHECKPOINT_SOURCE_KEYS = {"transactionId", "blockhash", "lastValidBlockHeight"} +OTHER_WARP_PROGRAM_ADDRESS = "6HCbFm2P3NWG8SKhzvMLgQHBJAjZvBrbfP6uFtQKpyfd" # a redeployed warp route + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + monkeypatch.setattr(sol.time, "sleep", lambda seconds: None) + + +def module(fake=None, *, signer=None, checkpoints=None): + fake = fake or FakeSolanaClient() + keypair = signer or Keypair() + bridge = stub_bridge(checkpoints=checkpoints) + return SolModule(bridge, Solana(client=fake, signer=keypair)), fake, keypair + + +class ExplodingStore: + """A checkpoint store whose disk is full / read-only.""" + + def __init__(self): + self.attempts = [] + + def save(self, checkpoint): + self.attempts.append(checkpoint) + raise OSError("read-only file system") + + def load(self, checkpoint_id): # pragma: no cover - never reached + return None + + def list(self): # pragma: no cover - never reached + return [] + + def delete(self, checkpoint_id): # pragma: no cover - never reached + return None + + +def test_extract_message_id_reads_only_the_mailbox_dispatch_line(): + assert sl.extract_hyperlane_message_id(TRANSFER["logMessages"]) == EXPECTED_MESSAGE_ID + truncated = [line for line in TRANSFER["logMessages"] if "Dispatched message" not in line] + assert any("Paid IGP" in line for line in truncated) + assert sl.extract_hyperlane_message_id(truncated) is None + assert sl.extract_hyperlane_message_id(None) is None + assert sl.extract_hyperlane_message_id([]) is None + + +def test_extract_message_id_refuses_an_over_long_hex_id(): + """A 65+-hex id is not a 32-byte message id; truncating it to 64 would report a plausible wrong id.""" + over_long = "Program log: Dispatched message to 1634493807, ID 0x" + "a" * 65 + assert sl.extract_hyperlane_message_id([over_long]) is None + exact = "Program log: Dispatched message to 1634493807, ID 0x" + "a" * 64 + assert sl.extract_hyperlane_message_id([exact]) == "0x" + "a" * 64 + + +def test_build_returns_a_transaction_signed_only_by_the_unique_message_key(): + mod, fake, keypair = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT) + assert isinstance(call, SolCall) + tx = call.build() + assert isinstance(tx, VersionedTransaction) + message = tx.message + assert message.header.num_required_signatures == 2 + assert message.account_keys[0] == keypair.pubkey() # fee payer first + unique = Pubkey.from_string(call.quote.unique_message_address) + assert message.account_keys[1] == unique + assert tx.signatures[0] == Signature.default() # fee payer unsigned + assert tx.signatures[1] != Signature.default() + assert tx.signatures[1].verify(unique, to_bytes_versioned(message)) + programs = [str(message.account_keys[ix.program_id_index]) for ix in message.instructions] + assert programs == ["ComputeBudget111111111111111111111111111111", WARP_PROGRAM_ADDRESS] + assert fake.sent == [] and call.quote.total_lamports == 676_207_914_240 + + +def test_send_adds_the_fee_payer_signature_confirms_and_extracts_the_message_id(): + mod, fake, keypair = module() + result = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send() + assert isinstance(result, DispatchReceipt) + signature = fake.sent_signature() + assert result.transaction_id == signature and result.route_id == sl.SOLANA_ROUTE_ID + assert result.message_id == EXPECTED_MESSAGE_ID and result.amount_atomic == AMOUNT + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == EXPECTED_MESSAGE_ID + assert receipt.source_tx_id == signature and receipt.protocol == "hyperlane" + assert receipt.protocol_state["messageId"] == EXPECTED_MESSAGE_ID + assert "messageIdUnavailable" not in receipt.protocol_state + assert len(fake.sent) == 1 + tx = VersionedTransaction.from_bytes(fake.sent[0]) + assert all(signature != Signature.default() for signature in tx.signatures) + assert tx.signatures[0].verify(keypair.pubkey(), to_bytes_versioned(tx.message)) + tx.verify_and_hash_message() # raises if any signature is invalid + opts = fake.sent_opts[0] + assert isinstance(opts, sol.SendOptions) + assert opts.skip_preflight is False and opts.preflight_commitment == "confirmed" and opts.skip_confirmation is True + assert fake.status_calls == [True] # searchTransactionHistory + assert fake.transaction_calls == [("confirmed", 0)] # confirmed commitment, v0 + + +def test_send_with_a_non_keypair_signer(): + inner = Keypair() + + class RemoteSigner: + def pubkey(self): + return inner.pubkey() + + def sign_message(self, message: bytes): + return inner.sign_message(message) + + fake = FakeSolanaClient() + mod = SolModule(stub_bridge(), Solana(client=fake, signer=RemoteSigner())) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + tx = VersionedTransaction.from_bytes(fake.sent[0]) + assert tx.signatures[0].verify(inner.pubkey(), to_bytes_versioned(tx.message)) + assert result.receipt.status is Status.DELIVERY_PENDING + + +def test_send_refuses_a_plan_prepared_for_another_sender_before_any_read(): + mod, fake, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=TRANSFER["senderAddress"]).plan + fake.calls.clear() + with pytest.raises(ConfigurationError, match=f"Prepared sender {TRANSFER['senderAddress']} does not match connected account"): + mod.transfer_remote(RECIPIENT, amount_atomic=1, plan=plan).send() + assert fake.calls == [] and fake.sent == [] + + +def test_send_refuses_a_stale_or_foreign_plan(): + mod, fake, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1).plan + stale = dataclasses.replace(plan, registry_version="2020-01-01.stale.0") + with pytest.raises(RegistryVersionMismatchError): + mod.transfer_remote(RECIPIENT, plan=stale).send() + foreign = dataclasses.replace(plan, route_id="hyperlane:ethereum/eth->aleo/eth") + with pytest.raises(UnsupportedRouteError): + mod.transfer_remote(RECIPIENT, plan=foreign).send() + assert fake.sent == [] + + +def test_send_uses_the_plan_when_it_matches_the_wallet(): + mod, fake, keypair = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=3).plan + assert plan.sender == str(keypair.pubkey()) + result = mod.transfer_remote(RECIPIENT, amount_atomic=3, plan=plan).send() + assert result.receipt.protocol_state["quotedLamports"] == str(3 + 2_900_000 + 10_000 + 5_004_240) + + +def test_send_insufficient_balance_names_the_amount_gas_and_rent_split(): + mod, fake, _ = module(FakeSolanaClient(balance=0)) + with pytest.raises(InsufficientBalanceError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send() + message = str(excinfo.value) + for fragment in ("balance 0 lamports", "required 676207914240 lamports", "amount 676200000000", "gas 2910000", "rent 5004240"): + assert fragment in message + assert fake.sent == [] + + +def test_send_checkpoints_source_confirming_before_the_first_status_read(): + mod, fake, _ = module() + seen = [] + + def on_checkpoint(checkpoint): + fake.calls.append("checkpoint") + seen.append(checkpoint) + + mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send(on_checkpoint=on_checkpoint) + assert len(seen) == 1 + checkpoint = seen[0] + assert checkpoint.id == fake.sent_signature() + assert checkpoint.route == {"id": sl.SOLANA_ROUTE_ID, "registryVersion": mod.registry.version} + assert set(checkpoint.source) == CHECKPOINT_SOURCE_KEYS + assert checkpoint.source["transactionId"] == fake.sent_signature() + assert checkpoint.source["blockhash"] == str(BLOCKHASH) and checkpoint.source["lastValidBlockHeight"] == "100" + assert fake.calls.index("send_raw_transaction") < fake.calls.index("checkpoint") < fake.calls.index("get_signature_statuses") + + +def test_bound_store_saves_the_source_checkpoint_after_the_caller_callback(): + saved = [] + + class RecordingStore: + def save(self, checkpoint): + saved.append(checkpoint) + + def load(self, checkpoint_id): + return None + + def list(self): + return [] + + def delete(self, checkpoint_id): + return None + + mod, fake, _ = module(checkpoints=RecordingStore()) + seen = [] + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + assert [cp.id for cp in saved] == [fake.sent_signature()] == [cp.id for cp in seen] + + +def test_store_failure_after_broadcast_reports_the_signature_and_never_hides_it(): + """The transaction is already on the wire: the caller's callback must have run first, the + error must name the signature and the checkpoint, and no status poll may follow the failure.""" + store = ExplodingStore() + mod, fake, _ = module(checkpoints=store) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + signature = fake.sent_signature() + assert signature in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [signature] # callback ran before the store + assert [cp.id for cp in store.attempts] == [signature] + assert len(fake.sent) == 1 # broadcast happened exactly once + assert "get_signature_statuses" not in fake.calls # nothing polled after the failure + + +def test_send_failed_status_raises_naming_the_signature(): + mod, fake, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [2, "Custom"]})])) + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + assert fake.sent_signature() in str(excinfo.value) + + +def test_send_timeout_returns_a_pending_source_confirming_receipt(): + mod, fake, _ = module(FakeSolanaClient(statuses=[None])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() + assert result.message_id is None and "messageId" not in receipt.protocol_state + assert "get_transaction" not in fake.calls and len(fake.sent) == 1 + + +def test_send_expired_blockhash_returns_expired_without_resubmitting(): + mod, fake, _ = module(FakeSolanaClient(statuses=[None], blockhash_valid=False)) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.EXPIRED + assert receipt.protocol_state["blockhashExpired"] is True + assert fake.sent_signature() in receipt.protocol_state["sourceError"] + assert len(fake.sent) == 1 + + +def test_processed_is_never_reported_expired_and_skips_the_blockhash_probe(): + """A processed transaction has landed; calling it EXPIRED would invite a resend (double spend).""" + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed")], blockhash_valid=False) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() + assert "blockhashExpired" not in receipt.protocol_state + assert "is_blockhash_valid" not in fake.calls + assert len(fake.sent) == 1 + + +def test_a_status_row_without_a_confirmation_level_is_never_reported_expired(): + """err=None with no confirmationStatus means the transaction landed; only a MISSING row is unknown.""" + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(err=None, confirmation_status=None)], blockhash_valid=False) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() + assert "blockhashExpired" not in receipt.protocol_state + assert "is_blockhash_valid" not in fake.calls and len(fake.sent) == 1 + + +def test_log_fetch_failure_after_confirmation_degrades_to_message_id_unavailable(): + """The logs only carry the message id: an RPC failure there must not fail a settled transfer.""" + fake = FakeSolanaClient(get_transaction_error=RuntimeError("rpc")) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == fake.sent_signature() + assert receipt.protocol_state["messageIdUnavailable"] is True + assert "messageId" not in receipt.protocol_state and result.message_id is None + assert len(fake.sent) == 1 + + +def test_send_swallows_transient_status_read_errors(): + fake = FakeSolanaClient(statuses=[RuntimeError("rate limited"), RuntimeError("again"), FakeSignatureStatus()], blockhash_valid=RuntimeError("advisory")) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=5, poll_seconds=0) + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.status_calls) >= 3 + + +def test_send_persistent_status_errors_time_out_to_pending(): + mod, _, _ = module(FakeSolanaClient(statuses=[RuntimeError("down")])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + assert result.receipt.status is Status.SOURCE_CONFIRMING + + +def test_send_without_wait_skips_polling(): + mod, fake, _ = module() + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(wait=False) + assert result.receipt.status is Status.SOURCE_CONFIRMING and "get_signature_statuses" not in fake.calls + + +def test_finalized_counts_as_confirmed_and_missing_log_marks_message_id_unavailable(): + mod, fake, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], logs=[])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == fake.sent_signature() + assert receipt.protocol_state["messageIdUnavailable"] is True and result.message_id is None + processed_then_confirmed = FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed"), FakeSignatureStatus()]) + mod2, _, _ = module(processed_then_confirmed) + assert mod2.transfer_remote(RECIPIENT, amount_atomic=1).send(poll_seconds=0).receipt.status is Status.DELIVERY_PENDING + + +def test_send_requires_a_signer(): + fake = FakeSolanaClient() + read_only = SolModule(stub_bridge(), Solana(client=fake)) + with pytest.raises(ConfigurationError, match="read-only"): + read_only.transfer_remote(RECIPIENT, amount_atomic=1).build() + assert fake.sent == [] + + +def test_transfer_remote_accepts_a_plan_without_a_recipient(): + mod, fake, keypair = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=3).plan + result = mod.transfer_remote(plan=plan).send() # no positional recipient + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.sent) == 1 + with pytest.raises(ValueError, match="plan"): + mod.transfer_remote(plan=plan, amount_atomic=4) + assert mod.transfer_remote(plan=plan, amount_atomic=3) is not None # identical is fine + + +def test_build_compiles_against_the_re_resolved_route_not_the_one_snapshotted_at_transfer_remote(): + """The registry can move between transfer_remote() and build(); building the instruction from the + stale snapshot while the quote used the fresh route would sign against the wrong program.""" + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + original = mod.outbound_route() + moved = dataclasses.replace(original, metadata={**original.metadata, + "warpProgramAddress": OTHER_WARP_PROGRAM_ADDRESS}) + mod.outbound_route = lambda: moved # the live registry now says otherwise + transaction = call.build() + programs = [str(transaction.message.account_keys[ix.program_id_index]) for ix in transaction.message.instructions] + assert OTHER_WARP_PROGRAM_ADDRESS in programs and WARP_PROGRAM_ADDRESS not in programs + assert fake.sent == [] + + +def test_a_call_is_single_use_once_it_has_broadcast(): + """Re-sending the same call would sign a second transfer of the same funds.""" + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + call.send() + signature = fake.sent_signature() + with pytest.raises(BridgeError) as excinfo: + call.send() + message = str(excinfo.value) + assert message == (f"this call already broadcast {signature}; use bridge.sol.source_status(plan, receipt) " + "to follow it — do not resend") + assert len(fake.sent) == 1 + + +def test_a_lost_send_response_also_arms_the_single_use_guard(): + """The bytes may be on the wire; a resend is exactly what must not happen next.""" + fake = FakeSolanaClient(send_error=RuntimeError("connection reset by peer")) + mod, _, _ = module(fake) + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(BridgeError, match="may have been broadcast"): + call.send() + with pytest.raises(BridgeError, match="already broadcast"): + call.send() + assert len(fake.sent) == 1 + + +def test_a_failure_before_any_broadcast_leaves_the_call_usable(): + fake = FakeSolanaClient(balance=0) + mod, _, _ = module(fake) + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(InsufficientBalanceError): + call.send() + assert fake.sent == [] + fake.balance = 800_000_000_000 + result = call.send() # the guard was never armed + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.sent) == 1 + + +def test_build_stays_repeatable_after_a_send(): + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + call.send() + assert isinstance(call.build(), VersionedTransaction) # a preview never spends + assert len(fake.sent) == 1 + + +def test_a_lost_send_response_names_the_local_signature_and_never_polls_or_checkpoints(): + """The bytes may already be on the wire: losing the RPC answer must not lose the signature.""" + fake = FakeSolanaClient(send_error=RuntimeError("connection reset by peer")) + mod, _, _ = module(fake) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + assert fake.sent_signature() in message # the id the caller needs to investigate + assert "may have been broadcast" in message and "connection reset by peer" in message + assert fake.calls.count("send_raw_transaction") == 1 # exactly one attempt, never retried + assert "get_signature_statuses" not in fake.calls and seen == [] + + +def test_a_node_signature_that_differs_from_the_signed_one_is_refused_without_checkpointing(): + """Checkpointing the node's id would follow — and later resend — the wrong transaction.""" + fake = FakeSolanaClient(signature=STUB_SIGNATURE) + mod, _, _ = module(fake) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + assert fake.sent_signature() in message and str(STUB_SIGNATURE) in message + assert seen == [] and "get_signature_statuses" not in fake.calls diff --git a/bridge-sdk/tests/test_sol_status.py b/bridge-sdk/tests/test_sol_status.py new file mode 100644 index 0000000..7e9b71c --- /dev/null +++ b/bridge-sdk/tests/test_sol_status.py @@ -0,0 +1,106 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") + +from aleo_bridge.errors import BridgeError, CheckpointInvalidError +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_solana import BLOCKHASH, STUB_SIGNATURE, FakeSignatureStatus, FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import EXPECTED_MESSAGE_ID, TRANSFER + +SIGNATURE = str(STUB_SIGNATURE) + + +def setup(fake=None): + fake = fake or FakeSolanaClient() + mod = SolModule(stub_bridge(), Solana(client=fake)) + plan = mod.quote_transfer_remote(TRANSFER["recipientAleoAddress"], amount_atomic=1, sender=TRANSFER["senderAddress"]).plan + fake.calls.clear() + return mod, fake, plan + + +def receipt(plan, **state) -> Receipt: + return Receipt(id=SIGNATURE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=SIGNATURE, + protocol_state={"routeId": plan.route_id, **state}) + + +def test_confirmed_signature_advances_to_delivery_pending_with_message_id(): + mod, fake, plan = setup() + out = mod.source_status(plan, receipt(plan)) + assert out.status is Status.DELIVERY_PENDING and out.id == EXPECTED_MESSAGE_ID + assert out.protocol_state["messageId"] == EXPECTED_MESSAGE_ID and out.source_tx_id == SIGNATURE + assert fake.status_calls == [True] and fake.calls == ["get_signature_statuses", "get_transaction"] + + +def test_finalized_without_log_marks_message_id_unavailable(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], no_logs=True)) + out = mod.source_status(plan, receipt(plan)) + assert out.status is Status.DELIVERY_PENDING and out.id == SIGNATURE and out.protocol_state["messageIdUnavailable"] is True + + +def test_processed_and_unknown_without_lifetime_are_unchanged(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed")])) + original = receipt(plan) + assert mod.source_status(plan, original) == original + mod2, fake2, plan2 = setup(FakeSolanaClient(statuses=[None])) + assert mod2.source_status(plan2, receipt(plan2)) == receipt(plan2) + assert "is_blockhash_valid" not in fake2.calls + + +def test_a_status_row_without_a_confirmation_level_counts_as_processed(): + """A row with err=None and no confirmationStatus means the transaction EXISTS (processed at + least). Treating it as 'unknown' would probe the blockhash and report EXPIRED on a landed + transfer — an invitation to resend.""" + lifetime = {"blockhash": str(BLOCKHASH), "lastValidBlockHeight": "100"} + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(err=None, confirmation_status=None)], blockhash_valid=False) + mod, _, plan = setup(fake) + original = receipt(plan, **lifetime) + assert mod.source_status(plan, original) == original + assert "is_blockhash_valid" not in fake.calls + + +def test_unknown_signature_with_lifetime_checks_the_blockhash(): + lifetime = {"blockhash": str(BLOCKHASH), "lastValidBlockHeight": "100"} + mod, fake, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=True)) + assert mod.source_status(plan, receipt(plan, **lifetime)) == receipt(plan, **lifetime) + assert "is_blockhash_valid" in fake.calls + expired_mod, _, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=False)) + out = expired_mod.source_status(plan, receipt(plan, **lifetime)) + assert out.status is Status.EXPIRED and out.protocol_state["blockhashExpired"] is True + assert SIGNATURE in out.protocol_state["sourceError"] + flaky_mod, _, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=RuntimeError("rpc"))) + assert flaky_mod.source_status(plan, receipt(plan, **lifetime)) == receipt(plan, **lifetime) + + +def test_malformed_lifetime_is_a_checkpoint_error(): + mod, _, plan = setup(FakeSolanaClient(statuses=[None])) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash=str(BLOCKHASH))) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash=str(BLOCKHASH), lastValidBlockHeight="soon")) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash="not-base58-0OIl", lastValidBlockHeight="100")) + + +def test_failed_signature_raises_and_status_read_errors_propagate(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [1, "Custom"]})])) + with pytest.raises(BridgeError, match=SIGNATURE): + mod.source_status(plan, receipt(plan)) + down, _, plan = setup(FakeSolanaClient(statuses=[RuntimeError("rpc down")])) + with pytest.raises(RuntimeError): + down.source_status(plan, receipt(plan)) + + +def test_guards(): + mod, _, plan = setup() + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), status=Status.DELIVERY_PENDING)) + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), source_tx_id=None)) + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), protocol="xreserve")) + with pytest.raises(BridgeError, match="does not match"): + mod.source_status(plan, Receipt(id=SIGNATURE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=SIGNATURE, protocol_state={"routeId": "hyperlane:ethereum/eth->aleo/eth"})) diff --git a/bridge-sdk/tests/test_types.py b/bridge-sdk/tests/test_types.py new file mode 100644 index 0000000..9c2a927 --- /dev/null +++ b/bridge-sdk/tests/test_types.py @@ -0,0 +1,125 @@ +import dataclasses +import json + +import pytest + +import aleo_bridge +from aleo_bridge import types +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, Attestation, BridgeStatus, + ChainStatus, DispatchReceipt, Fee, GasQuote, Plan, PreparedTx, PrivacyReceipt, + Progress, Receipt, Status, Step, to_progress) + + +def _plan() -> Plan: + return Plan(route_id="hyperlane:aleo/wbtc->ethereum/wbtc", registry_version="2026-08-31.solana-deposits.1", + protocol="hyperlane", environment="mainnet", source_asset_id="aleo/wbtc", + destination_asset_id="ethereum/wbtc", amount="0.001", amount_atomic=100_000, + recipient="0x0000000000000000000000000000000000000001", sender=None, mint_mode="public", + steps=(Step("source-dispatch", "dispatch", "aleo-wallet", True), + Step("message-delivery", "wait-delivery", "protocol", False))) + + +def test_status_enum_and_sets(): + assert [s.value for s in Status] == [ + "PREPARED", "SOURCE_APPROVAL_PENDING", "SOURCE_SUBMISSION_PENDING", "SOURCE_CONFIRMING", "ATTESTATION_PENDING", + "DESTINATION_ACTION_REQUIRED", "DELIVERY_PENDING", "DESTINATION_CONFIRMING", "COMPLETED", "FAILED", "EXPIRED"] + assert Status.COMPLETED == "COMPLETED" and Status("FAILED") is Status.FAILED + assert TERMINAL == {Status.COMPLETED, Status.FAILED, Status.EXPIRED} + assert CALLER_BOUNDARIES == {Status.SOURCE_SUBMISSION_PENDING, Status.DESTINATION_ACTION_REQUIRED, + Status.COMPLETED, Status.FAILED, Status.EXPIRED} + assert json.dumps({"s": Status.COMPLETED}) == '{"s": "COMPLETED"}' + + +@pytest.mark.parametrize("status,expected", [ + (Status.SOURCE_SUBMISSION_PENDING, "resume"), (Status.DESTINATION_ACTION_REQUIRED, "complete"), + (Status.COMPLETED, "done"), (Status.FAILED, "failed"), (Status.EXPIRED, "failed"), + (Status.PREPARED, "wait"), (Status.SOURCE_APPROVAL_PENDING, "wait"), (Status.SOURCE_CONFIRMING, "wait"), + (Status.ATTESTATION_PENDING, "wait"), (Status.DELIVERY_PENDING, "wait"), (Status.DESTINATION_CONFIRMING, "wait"), +]) +def test_to_progress_table(status, expected): + receipt = Receipt(id="at1x", protocol="hyperlane", status=status, protocol_state={"routeId": "r"}) + plan = _plan() + progress = to_progress(plan, receipt) + assert progress.next == expected + assert progress.plan is plan and progress.receipt is receipt + # Error is only set for FAILED and EXPIRED statuses + if status in {Status.FAILED, Status.EXPIRED}: + assert progress.error is not None + else: + assert progress.error is None + + +def test_to_progress_error_derivation(): + """Test error field is derived from protocol_state for FAILED/EXPIRED statuses.""" + plan = _plan() + + # FAILED with destinationError (takes priority over sourceError) + receipt_de = Receipt(id="at1x", protocol="hyperlane", status=Status.FAILED, + protocol_state={"routeId": "r", "destinationError": "dest err", "sourceError": "src err"}) + assert to_progress(plan, receipt_de).error == "dest err" + + # FAILED with only sourceError + receipt_se = Receipt(id="at1x", protocol="hyperlane", status=Status.FAILED, + protocol_state={"routeId": "r", "sourceError": "src err only"}) + assert to_progress(plan, receipt_se).error == "src err only" + + # EXPIRED with neither error field (generates default message) + receipt_expired = Receipt(id="at1x", protocol="hyperlane", status=Status.EXPIRED, protocol_state={"routeId": "r"}) + progress = to_progress(plan, receipt_expired) + assert progress.error == "Bridge transfer ended in EXPIRED" + + +def test_to_progress_accepts_status_strings_and_requires_route_id(): + receipt = Receipt(id="at1x", protocol="hyperlane", status="COMPLETED", protocol_state={"routeId": "r"}) + assert to_progress(_plan(), receipt).next == "done" + with pytest.raises(ConfigurationError, match="routeId"): + to_progress(_plan(), Receipt(id="at1x", protocol="hyperlane", status=Status.COMPLETED)) + + +def test_plan_round_trip(): + plan = _plan() + d = plan.to_dict() + assert d["steps"][0] == {"id": "source-dispatch", "kind": "dispatch", "executor": "aleo-wallet", "irreversible": True} + assert Plan.from_dict(json.loads(json.dumps(d))) == plan + with pytest.raises(dataclasses.FrozenInstanceError): + plan.amount = "2" # type: ignore[misc] + + +def test_receipt_replace_and_defaults(): + r = Receipt(id="0xabc", protocol="xreserve", status=Status.ATTESTATION_PENDING, protocol_state={"routeId": "x"}) + assert r.source_tx_id is None and r.destination_tx_id is None and r.next_action is None + r2 = r.replace(status=Status.DESTINATION_ACTION_REQUIRED, next_action={"kind": "xreserve-private-mint", "chainId": "aleo"}) + assert r2.status is Status.DESTINATION_ACTION_REQUIRED and r.status is Status.ATTESTATION_PENDING + assert r2.protocol_state == {"routeId": "x"} and r2.next_action["kind"] == "xreserve-private-mint" + # replace() must copy protocol_state/next_action, never alias the source's dicts + r2.protocol_state["routeId"] = "mutated" + r2.next_action["kind"] = "mutated" + assert r.protocol_state == {"routeId": "x"} and r.next_action is None + r3 = r2.replace(status=Status.COMPLETED) # replace() with neither field explicit still copies, not aliases + r3.protocol_state["routeId"] = "again" + assert r2.protocol_state["routeId"] == "mutated" + + +def test_result_dataclasses(): + receipt = Receipt(id="at1x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, protocol_state={"routeId": "r"}) + dr = DispatchReceipt(transaction_id="at1x", route_id="r", message_id=None, amount_atomic=1, receipt=receipt) + assert dr.receipt.protocol_state["routeId"] == "r" + assert GasQuote("r", 44000, 159337, 1000000000, 402, 8174147).payment_microcredits == 8174147 + att = Attestation(payload=bytes(305), message_hash=bytes(32), attestation=bytes(65), status="complete") + assert len(att.payload) == 305 + assert PreparedTx("at1x", "{}").serialized == "{}" + assert PrivacyReceipt("at1s", "aleo/eth", "0.000000000000000001", 1, "shield").direction == "shield" + q = AleoHyperlaneQuote(kind="aleo-hyperlane", plan=_plan(), fees=(Fee("hook", "aleo", "aleo/aleo", "8.174147", False),), + amount_out="0.001", gas_limit=44000, gas_overhead=159337, gas_price=1000000000, + exchange_rate=402, payment_microcredits=8174147) + assert q.fees[0].estimated is False and q.kind == "aleo-hyperlane" + status = BridgeStatus(environment="mainnet", registry_version="v", chains=[ChainStatus("aleo", None, False, {})], pending=[]) + assert status.chains[0].can_sign is False + assert isinstance(Progress("wait", _plan(), receipt), Progress) + + +def test_all_types_exported_from_package(): + """Ensure all names in types.__all__ are re-exported from aleo_bridge.__all__.""" + assert set(types.__all__) <= set(aleo_bridge.__all__), \ + f"Missing exports: {set(types.__all__) - set(aleo_bridge.__all__)}" diff --git a/bridge-sdk/tests/test_units.py b/bridge-sdk/tests/test_units.py new file mode 100644 index 0000000..2f4ecfc --- /dev/null +++ b/bridge-sdk/tests/test_units.py @@ -0,0 +1,60 @@ +from decimal import Decimal + +import pytest + +from aleo_bridge.errors import InvalidAmountError +from aleo_bridge.units import format_decimal_amount, parse_decimal_amount, resolve_amount + + +@pytest.mark.parametrize("amount,decimals,expected", [ + ("100", 6, 100_000_000), ("0.5", 6, 500_000), ("1.5", 6, 1_500_000), ("0.01", 8, 1_000_000), + ("1.000000000000000001", 18, 10**18 + 1), ("42", 0, 42), ("0.123456", 6, 123_456), + (Decimal("0.5"), 6, 500_000), (7, 6, 7_000_000), (" 2 ", 6, 2_000_000), +]) +def test_parse_decimal_amount(amount, decimals, expected): + assert parse_decimal_amount(amount, decimals) == expected + + +@pytest.mark.parametrize("amount", ["0.1234567", "1e6", "-1", "1.", "", ".5", "1,5", "abc"]) +def test_parse_decimal_amount_rejects(amount): + with pytest.raises(InvalidAmountError): + parse_decimal_amount(amount, 6) + + +def test_parse_decimal_amount_rejects_unicode_digits(): + # Python's \d matches any Unicode decimal digit, not just ASCII 0-9; the regex must be [0-9] only. + with pytest.raises(InvalidAmountError): + parse_decimal_amount("٥٦", 6) # ARABIC-INDIC DIGIT FIVE/SIX — category Nd, not ASCII + + +def test_parse_decimal_amount_rejects_bad_types_and_decimals(): + with pytest.raises(InvalidAmountError): + parse_decimal_amount(1.5, 6) # type: ignore[arg-type] + with pytest.raises(InvalidAmountError): + parse_decimal_amount(True, 6) # type: ignore[arg-type] + with pytest.raises(InvalidAmountError): + parse_decimal_amount("1", -1) + + +def test_format_decimal_amount(): + assert format_decimal_amount(2_000_001, 6) == "2.000001" + assert format_decimal_amount(1_500_000, 6) == "1.5" + assert format_decimal_amount(100_000_000, 6) == "100" + assert format_decimal_amount(1, 18) == "0.000000000000000001" + assert format_decimal_amount(0, 6) == "0" + assert format_decimal_amount(42, 0) == "42" + with pytest.raises(InvalidAmountError): + format_decimal_amount(-1, 6) + + +def test_resolve_amount_exactly_one(): + assert resolve_amount(amount="0.001", amount_atomic=None, decimals=8) == 100_000 + assert resolve_amount(amount=None, amount_atomic=100_000, decimals=8) == 100_000 + with pytest.raises(InvalidAmountError, match="exactly one"): + resolve_amount(amount=None, amount_atomic=None, decimals=8) + with pytest.raises(InvalidAmountError, match="exactly one"): + resolve_amount(amount="1", amount_atomic=1, decimals=8) + with pytest.raises(InvalidAmountError): + resolve_amount(amount=None, amount_atomic=-5, decimals=8) + with pytest.raises(InvalidAmountError): + resolve_amount(amount=None, amount_atomic="5", decimals=8) # type: ignore[arg-type] diff --git a/bridge-sdk/tests/test_wait.py b/bridge-sdk/tests/test_wait.py new file mode 100644 index 0000000..2cc3f70 --- /dev/null +++ b/bridge-sdk/tests/test_wait.py @@ -0,0 +1,191 @@ +"""``lifecycle.wait`` — poll ``get_status`` to a caller boundary. + +Always stops at ``types.CALLER_BOUNDARIES`` plus any statuses in ``until``, tolerates transient +read errors (network/RPC hiccups) up to ``max_consecutive_errors`` before giving up, and treats a +timeout as "still in flight", never as failure. +""" +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import BridgeError, CheckpointInvalidError, ConfigurationError, PollingTimeoutError +from aleo_bridge.lifecycle import prepare, wait +from aleo_bridge.types import Attestation, Progress, Receipt, Status, to_progress +from tests.fakes.fake_bridge import ALEO_RECIPIENT, SOL_ADDRESS, FakeBridge +from tests.test_get_status import SIG, _inbound_private + + +def _sol_progress(b): + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + return plan, to_progress(plan, receipt) + + +def test_returns_immediately_when_next_is_not_wait_or_status_is_a_boundary(): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + resume = to_progress(plan, progress.receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING)) + assert wait(b, resume) is not None and wait(b, resume).next == "resume" and b.calls == [] + done = to_progress(plan, progress.receipt.replace(status=Status.COMPLETED)) + assert wait(b, done).next == "done" and b.calls == [] + + +def test_polls_until_completion_and_reports_only_changes(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + reads = iter([100, 100, 101]) + b.sol.balance = lambda: next(reads) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + updates = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_update=updates.append) + assert out.next == "done" and out.receipt.status is Status.COMPLETED + assert updates == [out] # two unchanged reads produced no update + assert slept == [0.0, 0.0] + + +def test_until_adds_stops_and_empty_until_is_an_error(monkeypatch): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + with pytest.raises(ConfigurationError, match="until"): + wait(b, to_progress(plan, receipt), until=[]) + calls = {"n": 0} + real_get = b.xreserve.get_attestation + + def flaky(message_hash, *, route=None): + calls["n"] += 1 + return None if calls["n"] == 1 else Attestation(payload, bytes.fromhex(message_hash[2:]), bytes.fromhex(SIG[2:]), "complete") + b.xreserve.get_attestation = flaky + updates = [] + out = wait(b, to_progress(plan, receipt), until=[Status.DESTINATION_ACTION_REQUIRED], poll_seconds=0, + timeout_seconds=10, on_update=updates.append) + assert calls["n"] == 2 and out.next == "complete" and updates == [out] + # string statuses are accepted in until + calls["n"] = 0 + assert wait(b, to_progress(plan, receipt), until=["DESTINATION_ACTION_REQUIRED"], poll_seconds=0, + timeout_seconds=10).next == "complete" + + +def test_until_rejects_an_unknown_status_name(): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + with pytest.raises(ConfigurationError): + wait(b, progress, until=["NOT_A_REAL_STATUS"]) + + +def test_timeout_carries_status_and_progress(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + b.sol.balance_lamports = 100 + clock = iter([0.0, 0.0, 5.0, 11.0]) + monkeypatch.setattr("aleo_bridge.lifecycle.time.monotonic", lambda: next(clock)) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + with pytest.raises(PollingTimeoutError, match="DELIVERY_PENDING") as exc: + wait(b, progress, poll_seconds=0.05, timeout_seconds=10) + assert exc.value.status is Status.DELIVERY_PENDING + assert isinstance(exc.value.progress, Progress) and exc.value.progress.next == "wait" + + +def test_poll_floor_and_negative_controls(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + reads = iter([100, 101]) + b.sol.balance = lambda: next(reads) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + wait(b, progress, poll_seconds=0.01, timeout_seconds=10) + assert slept == [0.1] # floor 0.1 unless exactly 0 + with pytest.raises(ConfigurationError): + wait(b, progress, poll_seconds=-1) + with pytest.raises(ConfigurationError): + wait(b, progress, timeout_seconds=-1) + + +def test_bound_store_tracks_changes_and_deletes_terminal(tmp_path, monkeypatch): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + plan, progress = _sol_progress(b) + from aleo_bridge.checkpoint import create_checkpoint + store.save(create_checkpoint(plan, progress.receipt, b.registry)) + b.sol.balance_lamports = 101 + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + assert wait(b, progress, poll_seconds=0, timeout_seconds=10).next == "done" + assert store.list() == [] + + +# ── transient-error tolerance (controller notes item 1) ─────────────────────── + +def test_transient_errors_are_retried_with_the_normal_poll_interval(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + calls = {"n": 0} + + def flaky(bridge, plan_, receipt): + calls["n"] += 1 + if calls["n"] <= 3: + raise BridgeError("Solana RPC request failed with HTTP status 429") + return receipt.replace(status=Status.COMPLETED) + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", flaky) + errors = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_error=errors.append) + assert calls["n"] == 4 + assert out.next == "done" and out.receipt.status is Status.COMPLETED + assert len(errors) == 3 and all(isinstance(e, BridgeError) for e in errors) + assert slept == [0.0, 0.0, 0.0] + + +def test_six_consecutive_transient_errors_reraise_the_sixth(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + calls = {"n": 0} + + def always_flaky(bridge, plan_, receipt): + calls["n"] += 1 + raise BridgeError("request failed: connection reset") + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", always_flaky) + with pytest.raises(BridgeError, match="connection reset"): + wait(b, progress, poll_seconds=0, timeout_seconds=10) + assert calls["n"] == 6 # five tolerated, the sixth re-raises + + +def test_a_flaky_destination_balance_reader_is_retried_not_swallowed(monkeypatch): + """Task 6 review item 8, end to end: the branch-6 balance read raises a 429-shaped transport + error through the REAL ``get_status``, and ``wait`` retries it until the delivery is visible.""" + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + calls = {"n": 0} + + def flaky(): + calls["n"] += 1 + if calls["n"] <= 2: + raise BridgeError("Solana RPC request failed with HTTP status 429") + return 101 + + b.sol.balance = flaky + errors = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_error=errors.append) + assert calls["n"] == 3 and len(errors) == 2 + assert out.next == "done" and out.receipt.status is Status.COMPLETED + + +def test_a_non_transient_error_propagates_on_the_first_attempt(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + calls = {"n": 0} + + def raises_checkpoint_invalid(bridge, plan_, receipt): + calls["n"] += 1 + raise CheckpointInvalidError("stale checkpoint") + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", raises_checkpoint_invalid) + with pytest.raises(CheckpointInvalidError, match="stale checkpoint"): + wait(b, progress, poll_seconds=0, timeout_seconds=10) + assert calls["n"] == 1 diff --git a/bridge-sdk/tests/test_xreserve.py b/bridge-sdk/tests/test_xreserve.py new file mode 100644 index 0000000..5c69967 --- /dev/null +++ b/bridge-sdk/tests/test_xreserve.py @@ -0,0 +1,166 @@ +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.errors import (AttestationError, BridgeError, ConfigurationError, InvalidAmountError, + InvalidRecipientError, UnsupportedRouteError) +from aleo_bridge.registry import DEFAULT_REGISTRY as REG +from aleo_bridge.types import Attestation, BurnReceipt, MintReceipt, Status +from tests.conftest import NULLIFIED_NONCE, USDCX_RECORD + +EVM1 = "0x0000000000000000000000000000000000000001" +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +PROOF = "[{ siblings: [0field], leaf_index: 1u32 }, { siblings: [0field], leaf_index: 1u32 }]" +ONE_LIT = "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" +MAIN = REG.route("xreserve:aleo/usdcx->ethereum/usdc") +TESTNET = REG.route("xreserve:aleo-testnet/usdcx->sepolia/usdc") +INBOUND = REG.route("xreserve:ethereum/usdc->aleo/usdcx") + + +def test_routes(bridge): + assert bridge.xreserve.outbound_route().id == "xreserve:aleo/usdcx->ethereum/usdc" + assert bridge.xreserve.inbound_route().id == "xreserve:ethereum/usdc->aleo/usdcx" + + +def test_private_burn_inputs_match_veil(bridge): + program, function, inputs = bridge.xreserve.build_burn_inputs( + MAIN, mode="private", amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + assert (program, function) == ("shielded_usdcx_wrapper.aleo", "private_burn") + assert inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, PROOF] + program, function, inputs = bridge.xreserve.build_burn_inputs( + TESTNET, mode="private", amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + assert program == "shielded_usdcx_wrapper.aleo" and inputs[0] == USDCX_RECORD + + +def test_public_burn_inputs(bridge): + assert bridge.xreserve.build_burn_inputs(MAIN, mode="public", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None) == \ + ("usdcx_bridge_v2.aleo", "burn_public", ["2500000u128", "0u32", ONE_LIT]) + assert bridge.xreserve.build_burn_inputs(MAIN, mode="public-as-signer", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None)[1] == "burn_public_as_signer" + assert bridge.xreserve.build_burn_inputs(TESTNET, mode="public", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None)[0] == "test_usdcx_bridge_v2.aleo" + + +def test_burn_input_validation(bridge): + kw = dict(amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + with pytest.raises(ConfigurationError, match="Unsupported USDCx burn mode"): + bridge.xreserve.build_burn_inputs(MAIN, mode="unknown", **kw) + with pytest.raises(ConfigurationError, match="private_burn requires a USDCx Token record"): + bridge.xreserve.build_burn_inputs(MAIN, mode="private", **{**kw, "record": None}) + with pytest.raises(ConfigurationError, match=r"\[MerkleProof; 2\]"): + bridge.xreserve.build_burn_inputs(MAIN, mode="private", **{**kw, "merkle_proof": "not-a-literal"}) + with pytest.raises(InvalidAmountError, match="must exceed the 2 USDCx withdrawal fee"): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "amount_atomic": 2_000_000}) + with pytest.raises(InvalidAmountError, match="greater than zero"): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "amount_atomic": 0}) + with pytest.raises(InvalidRecipientError): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "recipient": "0x1234"}) + with pytest.raises(UnsupportedRouteError, match="Aleo-to-Ethereum"): + bridge.xreserve.build_burn_inputs(INBOUND, mode="public", **{**kw, "recipient": RECIPIENT}) + + +def test_burn_builds_call_and_receipt(bridge): + call = bridge.xreserve.burn(EVM1, amount="2.5", mode="private", record=USDCX_RECORD, merkle_proof=PROOF) + assert (call.program_id, call.function_name) == ("shielded_usdcx_wrapper.aleo", "private_burn") + assert call.inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, PROOF] + result = call.transact() + assert isinstance(result, BurnReceipt) + assert (result.transaction_id, result.route_id, result.mode, result.amount_atomic) == ("at1built", MAIN.id, "private", 2_500_000) + assert result.receipt.status is Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == "at1built" + assert result.receipt.protocol_state == { + "routeId": MAIN.id, "burnMode": "private", "amountAtomic": "2500000", "nativeDomain": 0, + "nativeRecipientBytes32": "0x" + "00" * 31 + "01", "sourceProgram": "shielded_usdcx_wrapper.aleo", + "sourceFunction": "private_burn", "forwardingService": "aleo-burn-attestation"} + public = bridge.xreserve.burn(EVM1, amount_atomic=3_000_000, mode="public-as-signer") + assert (public.program_id, public.function_name, public.inputs) == ("usdcx_bridge_v2.aleo", "burn_public_as_signer", ["3000000u128", "0u32", ONE_LIT]) + + +def test_burn_validates_mode_before_any_amount_or_chain_work(bridge): + with pytest.raises(ConfigurationError, match="Unsupported USDCx burn mode"): + bridge.xreserve.burn(EVM1, mode="unknown") # no amount= given: an amount check first would raise a different error + assert bridge.aleo.record_queries == [] # never reached the record scan + + +def test_burn_rejects_record_or_merkle_proof_for_public_modes(bridge): + with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + bridge.xreserve.burn(EVM1, amount="2.5", mode="public", record=USDCX_RECORD) + with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + bridge.xreserve.burn(EVM1, amount="2.5", mode="public-as-signer", merkle_proof="[x]") + assert bridge.aleo.record_queries == [] + + +def _attested(bridge, nonce="7scalar", recipient=RECIPIENT) -> Attestation: + hook = bridge.xreserve.hook_data("private", recipient, nonce) + payload = bytes.fromhex("5a2e0acd00000001") + bytes(228) + bytes.fromhex("00000041") + hook + return Attestation(payload=payload, message_hash=enc.xreserve_message_hash(payload), attestation=bytes.fromhex("11" * 65), status="complete") + + +def test_hook_data_uses_bridge_environment(bridge): + assert bridge.xreserve.hook_data("public", RECIPIENT) == bytes(65) + assert bridge.xreserve.hook_data("private", RECIPIENT, "7scalar") == enc.xreserve_hook_data("private", RECIPIENT, "mainnet", "7scalar") + + +def test_private_mint_inputs_match_veil(bridge): + att = _attested(bridge) + inputs = bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "7scalar") + assert len(inputs) == 5 + assert inputs[0] == enc.u8_array_literal(att.payload) and inputs[0].count("u8") == 305 and " " not in inputs[0] + assert inputs[1] == "[" + ",".join(["17u8"] * 65) + "]" + assert inputs[2] == enc.u8_array_literal(att.message_hash) and inputs[2].count("u8") == 32 + assert inputs[3] == "7scalar" and inputs[4] == RECIPIENT + + +def test_private_mint_rejections(bridge): + att = _attested(bridge) + with pytest.raises(AttestationError, match="do not match the attested hook"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "8scalar") + with pytest.raises(AttestationError, match="do not match the attested hook"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px", "7scalar") + with pytest.raises(AttestationError, match="completed Circle attestation"): + bridge.xreserve.build_private_mint_inputs(INBOUND, Attestation(att.payload, att.message_hash, att.attestation, "pending"), RECIPIENT, "7scalar") + with pytest.raises(AttestationError, match="invalid message hash"): + bridge.xreserve.build_private_mint_inputs(INBOUND, Attestation(att.payload, bytes(32), att.attestation, "complete"), RECIPIENT, "7scalar") + with pytest.raises(ConfigurationError, match="scalar"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "seven") + with pytest.raises(UnsupportedRouteError, match="Ethereum-to-Aleo"): + bridge.xreserve.build_private_mint_inputs(MAIN, att, RECIPIENT, "7scalar") + + +def test_private_mint_call_and_receipt(bridge): + att = _attested(bridge) + call = bridge.xreserve.private_mint(att, RECIPIENT, secret_nonce="7scalar") + assert (call.program_id, call.function_name) == ("shielded_usdcx_wrapper.aleo", "private_mint") + result = call.delegate(wait=False) + assert isinstance(result, MintReceipt) and result.transaction_id == "at1delegated" and result.route_id == INBOUND.id + r = result.receipt + assert r.id == enc.to_hex(att.message_hash) and r.status is Status.DESTINATION_CONFIRMING and r.destination_tx_id == "at1delegated" + assert r.protocol_state["routeId"] == INBOUND.id and r.protocol_state["mintMode"] == "private" + assert r.protocol_state["intendedRecipient"] == RECIPIENT and r.protocol_state["nonce"] == "0x" + "00" * 32 + assert r.protocol_state["destinationProgram"] == "shielded_usdcx_wrapper.aleo" and r.protocol_state["destinationFunction"] == "private_mint" + assert "secretNonce" not in r.protocol_state # the secret never travels in receipts + + +def test_get_attestation_uses_route_base_url(bridge): + class _Session: + def __init__(self): self.urls = [] + def get(self, url, timeout=None): + self.urls.append(url) + class R: status_code = 404 + return R() + session = _Session() + bridge.xreserve.circle_session = session + assert bridge.xreserve.get_attestation("0x" + "22" * 32) is None + assert session.urls == ["https://xreserve-api.circle.com/v1/attestations/0x" + "22" * 32] + bridge.xreserve.get_attestation(bytes.fromhex("33" * 32), route=REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx")) + assert session.urls[-1].startswith("https://xreserve-api-testnet.circle.com/v1/attestations/0x33") + + +def test_get_attestation_rejects_non_hex_message_hash(bridge): + with pytest.raises(BridgeError): + bridge.xreserve.get_attestation("nope") + + +def test_is_delivered_reads_bridge_program_nullifier(bridge): + assert bridge.xreserve.is_delivered(NULLIFIED_NONCE) is True + assert bridge.xreserve.is_delivered("0x" + NULLIFIED_NONCE.hex()) is True + assert bridge.xreserve.is_delivered(bytes(32)) is False + assert "usdcx_bridge_v2.aleo" in bridge.aleo.fetched + with pytest.raises(ConfigurationError, match="32-byte deposit nonce"): + bridge.xreserve.is_delivered("0x01")