diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index 1a6f8ce6..a7ff9c50 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -3,12 +3,19 @@ > 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 +Typed Python client that moves assets between Aleo, Ethereum, Arc, Base, Arbitrum and Solana +over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments (`pip install aleo-bridge-sdk`, 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`. +Registry version `2026-09-28.cctp-arc.1`. +CCTP supports Arc ↔ Ethereum/Base/Arbitrum. Pass `cctp={"speed": "fast", +"forwarding": True, "max_fee": "0.1"}` to quote; execute the returned plan to retain its ceiling. +Use `Bridge(..., evm={"arc": Ethereum(...), "base": Ethereum(...)})` for route-selected connections. +Arc native gas uses 18 decimals; ERC-20 USDC uses 6. They spend the same balance. +CCTP completion requires the exact message and mint receipt; a consumed nonce alone stays pending. +For stalled forwarding, `complete(progress, manual_mint=True)` explicitly authorizes a destination mint. +Recovery keeps the approved fee ceiling; never rerun execute after a broadcast. Runnable examples ship in the package: start with `python -m aleo_bridge.examples.quote_transfer --help`. @@ -36,7 +43,7 @@ assert progress.next == "done", progress.error 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'"` +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None, evm: '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``. @@ -54,7 +61,7 @@ back are dict entries instead — ``{"next": "failed", "error", "error_type"}`` when no store is bound. Finish any entry with ``recover`` → ``wait`` / ``resume`` / ``complete``, never by starting a new transfer. -### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` +### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar', cctp=None)` Price a transfer and get the plan that ``execute`` takes. Nothing is signed. @@ -102,13 +109,15 @@ 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)` +### `recover(self, checkpoint, *, approval_replacement=None)` 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``. +CCTP can adopt an explicitly selected, confirmed ``approval_replacement``; +its original transaction must be absent and no burn may be submitted. ### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` @@ -118,13 +127,16 @@ 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')` +### `complete(self, progress, *, secret_nonce: 'str | None' = None, on_checkpoint=None, proving: 'str' = 'delegate', manual_mint: 'bool' = False)` -Submit the private USDCx mint (``progress.next == "complete"``). +Claim a private USDCx or native-USDC CCTP destination mint. 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. +``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret +nonce, but requires a destination signer and gas. Set ``manual_mint=True`` +to explicitly authorize fallback for stalled forwarding; an already +submitted destination transaction is observed rather than repeated. ### `pending(self) -> 'list'` @@ -255,9 +267,9 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. -### `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]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` -Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a +Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. @@ -374,6 +386,14 @@ re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is | `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 | +| `xreserve:arc/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->arc/usdc` | xreserve | mainnet | active | +| `cctp:ethereum/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->ethereum/usdc` | cctp | mainnet | active | +| `cctp:base/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->base/usdc` | cctp | mainnet | active | +| `cctp:arbitrum/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->arbitrum/usdc` | cctp | mainnet | active | `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 index 05b2df02..56587dea 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -777,3 +777,81 @@ Applications that need to keep transaction contents out of the proving service can configure local proving with `proving="local"` on `execute`, `resume`, or `complete`. The application's machine then generates the proof and may need to download proving parameters. +# Arc and native USDC + +Bridge USDC between Arc and Aleo with xReserve, or between Arc and Ethereum, +Base, or Arbitrum with CCTP V2. All eight directions are mainnet routes. +Existing Ethereum and Solana calls keep their defaults. + +```python +from aleo_bridge import Bridge, Ethereum + +bridge = Bridge(aleo, evm={ + "ethereum": Ethereum(ethereum_rpc, private_key=evm_key), + "arc": Ethereum(arc_rpc), # Reads destination delivery; forwarding needs no destination signer. +}) +quote = bridge.quote( + source_chain="ethereum", source_asset="usdc", destination_chain="arc", + amount="5", recipient=recipient, + cctp={"speed": "fast", "forwarding": True, "max_fee": "0.1"}, +) +# After reviewing quote.amount_out and quote.fees: +progress = bridge.execute(quote.plan, on_checkpoint=save_checkpoint) +``` + +`cctp` defaults to standard finality and forwarding. `max_fee` is a decimal USDC +ceiling. When omitted, the quote adds 10% headroom to current protocol and forwarding +fees, rounded up to the next USDC atomic unit and capped below the transfer amount. +Execute the returned plan to preserve that visible ceiling. Explicit caps are never +increased. Fees are refreshed before approval and burn. If fees exceed the cap after +approval, execution returns `SOURCE_SUBMISSION_PENDING` (`next="resume"`) with a +`sourceError` explanation. Resume that progress when fees fall within its saved cap; +no burn is submitted while fees exceed it. A restart can recover the approval checkpoint +and resume the same transfer. Do not execute a new transfer to retry a submitted one. +For forwarded delivery, `amount_out` deducts the full approved budget, while the +verified destination mint may deduct less. CCTP transfers and addresses are public. + +CCTP recovery scans at most ten 1,000-block log batches per status call. Keep polling +the same client to reconcile older approvals or backfill older destination receipts. +Scan progress is held in memory and anchored to a checked block hash; restarting the +client safely restarts the scan. Incomplete source scans never authorize another burn, +and a used destination nonce alone never substitutes for mint receipt verification. + +Use `bridge.evm("arc")` to read the Arc connection. Environment-based setup recognizes +`ARC_RPC_URL`, `BASE_RPC_URL`, and `ARBITRUM_RPC_URL`, sharing `EVM_PRIVATE_KEY` +when present. These mainnet-only RPC variables are ignored for testnet clients. +Explicit `evm` connections take precedence and must match the client's network. Read-only connections +do not need a key. Arc gas balances use 18 decimals; the USDC token interface uses +6 decimals. These are two views of the same funds, so keep a gas reserve rather +than counting them as separate assets. + +Recover every interrupted transfer from its checkpoint. CCTP checkpoints retain +the source sender, options, fee ceiling, and submitted transaction identifiers; +they exclude message and attestation bodies. Recovery reads chain evidence and +does not sign. An approval with no visible transaction or receipt stays pending. +If you selected a confirmed replacement after verifying that the original is no +longer visible, pass `approval_replacement={"original_transaction_id": old_hash, +"replacement_transaction_id": new_hash}` to `recover`. The original hash remains +in the saved history. Replacement is refused after a burn has been submitted. + +Set `forwarding=False` to submit the destination mint yourself with +`bridge.complete(progress)`. For stalled forwarding, explicitly authorize fallback +with `bridge.complete(progress, manual_mint=True)`. This requires a destination +signer and native gas. Completion refreshes the attestation and nonce first and +does not repeat a known pending destination transaction. A consumed nonce alone +does not prove delivery: the matching message event and exact USDC mint are required. + +For Arc → Aleo, select `source_chain="arc"`, `source_asset="usdc"`, and +`destination_chain="aleo"`; public, record, and private mint modes work as on +Ethereum. Private mint completion still requires the original secret nonce. +Aleo → Arc burns use domain 26, enforce a two-USDCx minimum, and fetch a live +withdrawal fee estimate again before proving. Provider errors are surfaced instead +of presenting a static estimate as current. Public outbound xReserve delivery is +observed through the recipient balance; it has no CCTP-style transaction proof. + +Checkpoints from registry `2026-08-31.solana-deposits.1` remain recoverable only +for the 22 original routes whose pinned deployment fingerprints still match. +Unknown versions and old version labels attached to new routes are rejected. + +Runnable Arc examples and explicit live-test gates are described in +[examples/README.md](examples/README.md). No live test runs by default. diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py index c521ca5f..4e872d72 100644 --- a/bridge-sdk/codegen/gen_context.py +++ b/bridge-sdk/codegen/gen_context.py @@ -181,12 +181,19 @@ def render() -> str: "> 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", + "Typed Python client that moves assets between Aleo, Ethereum, Arc, Base, Arbitrum and Solana", + "over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments", "(`pip install aleo-bridge-sdk`, 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}`.", + "CCTP supports Arc ↔ Ethereum/Base/Arbitrum. Pass `cctp={\"speed\": \"fast\",", + "\"forwarding\": True, \"max_fee\": \"0.1\"}` to quote; execute the returned plan to retain its ceiling.", + "Use `Bridge(..., evm={\"arc\": Ethereum(...), \"base\": Ethereum(...)})` for route-selected connections.", + "Arc native gas uses 18 decimals; ERC-20 USDC uses 6. They spend the same balance.", + "CCTP completion requires the exact message and mint receipt; a consumed nonce alone stays pending.", + "For stalled forwarding, `complete(progress, manual_mint=True)` explicitly authorizes a destination mint.", + "Recovery keeps the approved fee ceiling; never rerun execute after a broadcast.", "", "Runnable examples ship in the package: start with", "`python -m aleo_bridge.examples.quote_transfer --help`.", diff --git a/bridge-sdk/examples/README.md b/bridge-sdk/examples/README.md index 32493229..ad55c677 100644 --- a/bridge-sdk/examples/README.md +++ b/bridge-sdk/examples/README.md @@ -217,3 +217,45 @@ Each script presents the transfer as a tutorial, with inline comments explaining what happens to the funds and when another action is needed. Client setup, quotes, submission, progress, and error handling remain explicit in each script. Only command-line argument definitions are shared in [_arguments.py](_arguments.py). +# Arc journeys + +The following commands preview by default. Supply the RPC variables for each EVM +chain involved (`ARC_RPC_URL`, `ETHEREUM_RPC_URL`, `BASE_RPC_URL`, +`ARBITRUM_RPC_URL`). `--execute` loads `EVM_PRIVATE_KEY`; an Aleo-origin public +burn also needs `ALEO_PRIVATE_KEY`. Keep Aleo credits and native EVM gas funded. + +```sh +python -m aleo_bridge.examples.bridge_arc_to_aleo --sender 0xYOUR_ADDRESS --recipient aleo1YOUR_ADDRESS --amount 5 +python -m aleo_bridge.examples.bridge_ethereum_arc_aleo --sender 0xYOUR_ADDRESS --recipient aleo1YOUR_ADDRESS --amount 5 +python -m aleo_bridge.examples.l2_arc_aleo_roundtrip --l2 base --step 1 --sender 0xYOUR_ADDRESS --recipient aleo1YOUR_ADDRESS --amount 5 +``` + +The four-step example runs one explicit leg per invocation: L2 → Arc, Arc → Aleo, +Aleo → Arc, and Arc → L2. Use `--l2 arbitrum` for Arbitrum. Each later step reads +the previous completed leg's net receipt budget; it never spends a pre-existing +balance as return principal. Use your own Aleo recipient when you intend to return +the public USDCx. The xReserve withdrawal's balance observation is labeled as such. + +All examples save each leg under `--journal` (default `~/.aleo-bridge/arc-journey`). +Rerun with the same arguments to recover, never a new journal to retry uncertain +submissions. Keep the state files. Run one process per journey. If submission +started but no checkpoint was saved, the example refuses another transfer and +requires inspection of source history. A provider handoff, pending attestation, +or timeout does not advance to the next leg. + +`--arc-gas-reserve 0.10` leaves that much received USDC on Arc before spending +the remainder. This is a configurable budget, not a guarantee of future gas cost. +`--manual-mint` explicitly authorizes CCTP fallback if forwarding stalls; it needs +a funded destination signer. + +Read-only integration checks require `BRIDGE_LIVE_READS=1` and the relevant RPC +variables. Funded checks additionally require the existing `BRIDGE_LIVE_FUNDS=1`, +an external `BRIDGE_LIVE_STATE_DIR`, and `BRIDGE_LIVE_MAINNET_ACK` acknowledgment. +Choose `evm-cctp`, `evm-xreserve`, or `aleo-xreserve` in +`BRIDGE_LIVE_MAINNET_CASES` for individual routes; choose `cctp-roundtrip` or +`arc-journey` for received-only roundtrips or four-leg public journeys. The +`aleo-arc` case burns a two-USDCx private record with a 0.10-USDC fee budget and +requires `BRIDGE_LIVE_ARC_RECIPIENT`; its Arc connection has no signer and it +verifies the exact destination transfer receipt and balance delta. +Without the explicit `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgment they only quote. +The tests never set any gate variables themselves. diff --git a/bridge-sdk/examples/_arc_workflow.py b/bridge-sdk/examples/_arc_workflow.py new file mode 100644 index 00000000..ab1a8bdc --- /dev/null +++ b/bridge-sdk/examples/_arc_workflow.py @@ -0,0 +1,156 @@ +"""Durable public bridge legs for the Arc examples. Preview is the default.""" +from __future__ import annotations + +import argparse +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from aleo_bridge import Bridge, Ethereum, create_checkpoint +from aleo_bridge.errors import BridgeError +from aleo_bridge.units import format_decimal_amount, parse_decimal_amount + + +def spendable(received: int, reserve: str = '0.10') -> str: + """Leave an explicit USDC gas budget on Arc; this is not a gas estimate.""" + held = parse_decimal_amount(reserve, 6) + if held < 0 or held >= received: + raise BridgeError('Arc gas reserve must be below the received amount') + return format_decimal_amount(received-held, 6) + + +def _save(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix='.arc-') + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _balance(bridge: Bridge, route_id: str, recipient: str) -> int: + asset = bridge.registry.asset(bridge.registry.route(route_id).destination_asset_id) + if bridge.registry.chain(asset.chain_id).family == 'evm': + return bridge.evm(asset.chain_id).balance(asset.id,address=recipient) + from aleo_bridge.client import balance_program, parse_uint_literal + program = balance_program(asset) + if program is None: + raise BridgeError('No public balance reader for the destination') + value = bridge.mapping_value(program,'balances',recipient) + return parse_uint_literal(value) if value is not None else 0 + + +def run_leg(bridge: Bridge, *, route: str, amount: str, recipient: str, state_path: Path, + execute: bool = False, timeout: float = 120, cctp: dict[str, Any] | None = None, + manual_mint: bool = False, sender: str | None = None) -> int | None: + """Recover a saved leg before considering a new transfer; return received atomic units only when done. + + A checkpoint is saved immediately at every broadcast boundary. An interruption before the + first checkpoint leaves a marker and refuses another execute: inspect source history first. + Run one process per journey. Public Aleo-to-Arc completion is a balance observation, not + CCTP's exact event proof; the return budget never exceeds the quoted net withdrawal. + """ + state_path = Path(state_path).expanduser() + request = {'route': route, 'amount': amount, 'recipient': recipient, 'cctp': cctp, 'sender': sender} + state: dict[str, Any] = json.loads(state_path.read_text()) if state_path.exists() else {} + if state and state.get('request') != request: + raise BridgeError('Saved journey intent differs; use its original arguments') + if state.get('done'): + received = state.get('received_atomic') + if type(received) is not int or received <= 0 or not state.get('checkpoint'): + raise BridgeError('Invalid completed journey record') + verified = bridge.recover(state['checkpoint']) + if verified.next != 'done': + raise BridgeError('Saved completion could not be reverified; do not start another leg') + return received + def checkpoint(cp: Any) -> None: + state['checkpoint'] = cp.to_dict() + _save(state_path, state) + if state: + if not state.get('checkpoint'): + raise BridgeError('Submission began without a checkpoint; inspect source history before any new transfer') + progress = bridge.recover(state['checkpoint']) + if not execute: + print('Saved transfer:', progress.next) + return None + else: + quote = bridge.quote(route=bridge.registry.route(route), amount=amount, recipient=recipient, + sender=sender, **({'cctp': cctp} if cctp is not None else {})) + print(route, 'amount:', amount, 'estimated received:', quote.amount_out) + for fee in quote.fees: + print('Fee:', fee.kind, fee.amount, fee.asset_id) + if not execute: + return None + state = {'request': request, 'expected_atomic': parse_decimal_amount(quote.amount_out or amount, 6)} + if quote.plan.protocol == 'xreserve': + state['balance_before'] = _balance(bridge,route,recipient) + _save(state_path, state) + progress = bridge.execute(quote.plan, on_checkpoint=checkpoint, timeout_seconds=timeout, + **({'mode': 'public-as-signer'} if route.startswith('xreserve:aleo/') else {})) + if progress.next == 'resume': + progress = bridge.resume(progress, on_checkpoint=checkpoint, timeout_seconds=timeout) + if progress.next == 'complete' or (manual_mint and progress.plan.protocol == 'cctp' and + progress.receipt.status.value == 'DELIVERY_PENDING'): + progress = bridge.complete(progress, on_checkpoint=checkpoint, manual_mint=manual_mint) + if progress.next == 'wait': + progress = bridge.wait(progress, timeout_seconds=timeout) + checkpoint(create_checkpoint(progress.plan, progress.receipt, bridge.registry)) + if progress.next != 'done': + raise BridgeError(f'Journey stopped at {progress.next}; recover this leg before starting another') + if progress.plan.protocol == 'cctp': + from aleo_bridge._cctp_message import decode_message + message = decode_message(bytes.fromhex(progress.receipt.protocol_state['message'][2:])) + received = message.amount-message.fee + else: + if type(state.get('balance_before')) is not int: + raise BridgeError('Saved xReserve leg has no destination balance baseline; verify delivery manually') + observed = _balance(bridge,route,recipient)-state['balance_before'] + refreshed = int(progress.receipt.protocol_state.get('expectedDestinationIncreaseAtomic',state['expected_atomic'])) + received = min(observed,refreshed,state['expected_atomic']) + if received <= 0: + raise BridgeError('No received public balance observed; do not advance this journey') + print('xReserve delivery observed; net return budget:', received, 'atomic units') + state.update(done=True, received_atomic=received) + _save(state_path, state) + return received + + +def parser(description: str) -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=description) + p.add_argument('--amount', default='5') + p.add_argument('--recipient', required=True, help='Aleo recipient for the public mint.') + p.add_argument('--sender', required=True, help='EVM sender/recipient address, shared across EVM chains.') + p.add_argument('--execute', action='store_true', help='Submit or resume this MAINNET journey.') + p.add_argument('--journal', default='~/.aleo-bridge/arc-journey') + p.add_argument('--timeout', type=float, default=120) + p.add_argument('--arc-gas-reserve', default='0.10', help='USDC to retain on Arc; a budget, not a gas estimate.') + p.add_argument('--manual-mint', action='store_true', help='Authorize fallback for stalled CCTP forwarding.') + return p + + +def build_bridge(chains: tuple[str, ...], *, execute: bool, aleo_signer: bool = False) -> Bridge: + from aleo import Aleo, HTTPProvider + aleo = Aleo(HTTPProvider(os.environ.get('ALEO_RPC_URL', 'https://edge.provable.com/api'), network='mainnet')) + if execute and aleo_signer: + aleo.default_account = aleo.account.from_private_key(os.environ['ALEO_PRIVATE_KEY']) + evm = {chain: Ethereum(os.environ[f'{chain.upper()}_RPC_URL'], + private_key=os.environ['EVM_PRIVATE_KEY'] if execute else None) for chain in chains} + return Bridge(aleo, evm=evm) + + +def entrypoint(main: Any) -> None: + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print('Interrupted. Recover the saved journey before submitting again.') + raise SystemExit(130) + except Exception as exc: + print(f'{type(exc).__name__}: journey unfinished. Recover the saved leg; do not start a duplicate.') + raise SystemExit(2) diff --git a/bridge-sdk/examples/bridge_arc_to_aleo.py b/bridge-sdk/examples/bridge_arc_to_aleo.py new file mode 100644 index 00000000..69a32dd2 --- /dev/null +++ b/bridge-sdk/examples/bridge_arc_to_aleo.py @@ -0,0 +1,20 @@ +"""Preview or resume a public Arc USDC → Aleo USDCx transfer.""" +from pathlib import Path + +try: + from ._arc_workflow import build_bridge, entrypoint, parser, run_leg +except ImportError: + from _arc_workflow import build_bridge, entrypoint, parser, run_leg + + +def main(argv=None): + args = parser(__doc__).parse_args(argv) + bridge = build_bridge(('arc',), execute=args.execute) + run_leg(bridge, route='xreserve:arc/usdc->aleo/usdcx', amount=args.amount, + recipient=args.recipient, sender=args.sender, state_path=Path(args.journal).expanduser()/'arc-aleo.json', + execute=args.execute, timeout=args.timeout) + return 0 + + +if __name__ == '__main__': + entrypoint(main) diff --git a/bridge-sdk/examples/bridge_ethereum_arc_aleo.py b/bridge-sdk/examples/bridge_ethereum_arc_aleo.py new file mode 100644 index 00000000..e42099f0 --- /dev/null +++ b/bridge-sdk/examples/bridge_ethereum_arc_aleo.py @@ -0,0 +1,27 @@ +"""Preview Ethereum → Arc; after verified delivery, send received USDC to Aleo.""" +from pathlib import Path + +try: + from ._arc_workflow import build_bridge, entrypoint, parser, run_leg, spendable +except ImportError: + from _arc_workflow import build_bridge, entrypoint, parser, run_leg, spendable + + +def main(argv=None): + args = parser(__doc__).parse_args(argv) + bridge = build_bridge(('ethereum','arc'), execute=args.execute) + root = Path(args.journal).expanduser() + received = run_leg(bridge, route='cctp:ethereum/usdc->arc/usdc', amount=args.amount, + recipient=args.sender, sender=args.sender, state_path=root/'ethereum-arc.json', + execute=args.execute, timeout=args.timeout, manual_mint=args.manual_mint) + if received is None: + print('Arc → Aleo will be quoted from the verified receipt, less the Arc gas reserve.') + return 0 + run_leg(bridge, route='xreserve:arc/usdc->aleo/usdcx', amount=spendable(received,args.arc_gas_reserve), + recipient=args.recipient, sender=args.sender, state_path=root/'arc-aleo.json', + execute=args.execute, timeout=args.timeout) + return 0 + + +if __name__ == '__main__': + entrypoint(main) diff --git a/bridge-sdk/examples/l2_arc_aleo_roundtrip.py b/bridge-sdk/examples/l2_arc_aleo_roundtrip.py new file mode 100644 index 00000000..9f28a5ae --- /dev/null +++ b/bridge-sdk/examples/l2_arc_aleo_roundtrip.py @@ -0,0 +1,40 @@ +"""Run one explicit public leg of Base/Arbitrum → Arc → Aleo → Arc → L2.""" +import json +from pathlib import Path + +from aleo_bridge.errors import BridgeError +from aleo_bridge.units import format_decimal_amount + +try: + from ._arc_workflow import build_bridge, entrypoint, parser, run_leg, spendable +except ImportError: + from _arc_workflow import build_bridge, entrypoint, parser, run_leg, spendable + + +def main(argv=None): + p = parser(__doc__) + p.add_argument('--l2', choices=['base','arbitrum'], required=True) + p.add_argument('--step', type=int, choices=[1,2,3,4], required=True, + help='1 L2→Arc; 2 Arc→Aleo; 3 Aleo→Arc; 4 Arc→L2. Each step needs its own invocation.') + args = p.parse_args(argv) + bridge = build_bridge((args.l2,'arc'), execute=args.execute, aleo_signer=args.step == 3) + root = Path(args.journal).expanduser()/args.l2 + routes = [f'cctp:{args.l2}/usdc->arc/usdc','xreserve:arc/usdc->aleo/usdcx', + 'xreserve:aleo/usdcx->arc/usdc',f'cctp:arc/usdc->{args.l2}/usdc'] + amount = args.amount + if args.step > 1: + previous = json.loads((root/f'leg-{args.step-1}.json').read_text()) + if not previous.get('done') or previous.get('request',{}).get('route') != routes[args.step-2]: + raise BridgeError('Previous leg is not complete; recover it before proceeding') + received = previous['received_atomic'] + amount = spendable(received,args.arc_gas_reserve) if args.step in (2,4) else format_decimal_amount(received,6) + run_leg(bridge,route=routes[args.step-1],amount=amount, + recipient=args.recipient if args.step == 2 else args.sender, + sender=args.recipient if args.step == 3 else args.sender, + state_path=root/f'leg-{args.step}.json',execute=args.execute,timeout=args.timeout, + manual_mint=args.manual_mint) + return 0 + + +if __name__ == '__main__': + entrypoint(main) diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml index 67cf7291..e4d6ef81 100644 --- a/bridge-sdk/pyproject.toml +++ b/bridge-sdk/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aleo-bridge-sdk" version = "0.5.1" -description = "Python SDK for bridging assets between Aleo, Ethereum and Solana over Hyperlane warp routes and Circle xReserve" +description = "Bridge Aleo, Ethereum, Arc, Base, Arbitrum and Solana assets with Hyperlane, Circle xReserve and CCTP" readme = "README.md" requires-python = ">=3.10" # PyNaCl is NOT optional: every Aleo-side execution goes through delegated proving @@ -28,6 +28,10 @@ packages = ["python/aleo_bridge"] # Include only tutorial sources; local journals and checkpoints stay out. [tool.hatch.build.targets.wheel.force-include] +"examples/_arc_workflow.py" = "aleo_bridge/examples/_arc_workflow.py" +"examples/bridge_arc_to_aleo.py" = "aleo_bridge/examples/bridge_arc_to_aleo.py" +"examples/bridge_ethereum_arc_aleo.py" = "aleo_bridge/examples/bridge_ethereum_arc_aleo.py" +"examples/l2_arc_aleo_roundtrip.py" = "aleo_bridge/examples/l2_arc_aleo_roundtrip.py" "examples/__init__.py" = "aleo_bridge/examples/__init__.py" "examples/_arguments.py" = "aleo_bridge/examples/_arguments.py" "examples/bridge_sol.py" = "aleo_bridge/examples/bridge_sol.py" diff --git a/bridge-sdk/pyright-arc.json b/bridge-sdk/pyright-arc.json new file mode 100644 index 00000000..c1973a14 --- /dev/null +++ b/bridge-sdk/pyright-arc.json @@ -0,0 +1,5 @@ +{ + "extends": "pyrightconfig.json", + "include": ["python/aleo_bridge/_cctp_abi.py", "python/aleo_bridge/_cctp_message.py", "python/aleo_bridge/_registry_compatibility.py", "python/aleo_bridge/_evm_connections.py", "python/aleo_bridge/cctp.py"], + "typeCheckingMode": "strict" +} diff --git a/bridge-sdk/pytest.ini b/bridge-sdk/pytest.ini index 3204b8aa..150eaf05 100644 --- a/bridge-sdk/pytest.ini +++ b/bridge-sdk/pytest.ini @@ -2,6 +2,7 @@ pythonpath = python testpaths = tests markers = + slow: network or proving integration tests requiring explicit opt-in 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 index 1a6f8ce6..a7ff9c50 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -3,12 +3,19 @@ > 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 +Typed Python client that moves assets between Aleo, Ethereum, Arc, Base, Arbitrum and Solana +over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments (`pip install aleo-bridge-sdk`, 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`. +Registry version `2026-09-28.cctp-arc.1`. +CCTP supports Arc ↔ Ethereum/Base/Arbitrum. Pass `cctp={"speed": "fast", +"forwarding": True, "max_fee": "0.1"}` to quote; execute the returned plan to retain its ceiling. +Use `Bridge(..., evm={"arc": Ethereum(...), "base": Ethereum(...)})` for route-selected connections. +Arc native gas uses 18 decimals; ERC-20 USDC uses 6. They spend the same balance. +CCTP completion requires the exact message and mint receipt; a consumed nonce alone stays pending. +For stalled forwarding, `complete(progress, manual_mint=True)` explicitly authorizes a destination mint. +Recovery keeps the approved fee ceiling; never rerun execute after a broadcast. Runnable examples ship in the package: start with `python -m aleo_bridge.examples.quote_transfer --help`. @@ -36,7 +43,7 @@ assert progress.next == "done", progress.error 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'"` +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None, evm: '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``. @@ -54,7 +61,7 @@ back are dict entries instead — ``{"next": "failed", "error", "error_type"}`` when no store is bound. Finish any entry with ``recover`` → ``wait`` / ``resume`` / ``complete``, never by starting a new transfer. -### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` +### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar', cctp=None)` Price a transfer and get the plan that ``execute`` takes. Nothing is signed. @@ -102,13 +109,15 @@ 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)` +### `recover(self, checkpoint, *, approval_replacement=None)` 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``. +CCTP can adopt an explicitly selected, confirmed ``approval_replacement``; +its original transaction must be absent and no burn may be submitted. ### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` @@ -118,13 +127,16 @@ 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')` +### `complete(self, progress, *, secret_nonce: 'str | None' = None, on_checkpoint=None, proving: 'str' = 'delegate', manual_mint: 'bool' = False)` -Submit the private USDCx mint (``progress.next == "complete"``). +Claim a private USDCx or native-USDC CCTP destination mint. 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. +``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret +nonce, but requires a destination signer and gas. Set ``manual_mint=True`` +to explicitly authorize fallback for stalled forwarding; an already +submitted destination transaction is observed rather than repeated. ### `pending(self) -> 'list'` @@ -255,9 +267,9 @@ lifecycle layer (plan 4) re-quotes at the last responsible moment by calling thi Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. -### `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]'` +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[BurnReceipt]'` -Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a +Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. @@ -374,6 +386,14 @@ re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is | `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 | +| `xreserve:arc/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->arc/usdc` | xreserve | mainnet | active | +| `cctp:ethereum/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->ethereum/usdc` | cctp | mainnet | active | +| `cctp:base/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->base/usdc` | cctp | mainnet | active | +| `cctp:arbitrum/usdc->arc/usdc` | cctp | mainnet | active | +| `cctp:arc/usdc->arbitrum/usdc` | cctp | mainnet | active | `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 index b8a08741..aacbc810 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -18,7 +18,7 @@ ) 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, + CctpOptions, EvmCctpQuote, 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, @@ -46,7 +46,7 @@ "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", "UnsupportedRouteError", "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", - "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "CctpOptions", "EvmCctpQuote", "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/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index 80d18376..e86f9366 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -10,11 +10,16 @@ import json from dataclasses import dataclass -from typing import Any, Callable, Generic, TypeVar +from typing import Any, Callable, Generic, TypeVar, TYPE_CHECKING from .errors import BridgeError, ConfigurationError from .types import PreparedTx +if TYPE_CHECKING: + from .types import Plan + from .registry import Registry + from .checkpoint import Checkpoint, CheckpointStore + R = TypeVar("R") _DUPLICATE_MARKER = "already exists" diff --git a/bridge-sdk/python/aleo_bridge/_cctp_abi.py b/bridge-sdk/python/aleo_bridge/_cctp_abi.py new file mode 100644 index 00000000..c5ad6175 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_cctp_abi.py @@ -0,0 +1,30 @@ +"""Minimal CCTP V2 interfaces pinned to Veil PR #148.""" +from ._evm_abi import ERC20_ABI +from collections.abc import Sequence +from typing import Any + + +def function(name: str, inputs: Sequence[tuple[str, str]], outputs: Sequence[str] = (), view: bool = False) -> dict[str, Any]: + return {"type": "function", "name": name, "stateMutability": "view" if view else "nonpayable", + "inputs": [{"name": n, "type": t} for n, t in inputs], + "outputs": [{"name": "", "type": t} for t in outputs]} + + +def event(name: str, inputs: Sequence[tuple[str, str, bool]]) -> dict[str, Any]: + return {"type": "event", "name": name, "anonymous": False, + "inputs": [{"name": n, "type": t, "indexed": i} for n, t, i in inputs]} + + +BURN_INPUTS = [("amount", "uint256"), ("destinationDomain", "uint32"), ("mintRecipient", "bytes32"), + ("burnToken", "address"), ("destinationCaller", "bytes32"), ("maxFee", "uint256"), + ("minFinalityThreshold", "uint32")] +MESSENGER_ABI = [function("depositForBurn", BURN_INPUTS), + function("depositForBurnWithHook", BURN_INPUTS + [("hookData", "bytes")])] +TRANSMITTER_ABI = [function("usedNonces", [("nonce", "bytes32")], ["uint256"], True), + function("receiveMessage", [("message", "bytes"), ("attestation", "bytes")], ["bool"]), + event("MessageSent", [("message", "bytes", False)]), + event("MessageReceived", [("caller", "address", True), ("sourceDomain", "uint32", False), + ("nonce", "bytes32", True), ("sender", "bytes32", False), + ("finalityThresholdExecuted", "uint32", True), ("messageBody", "bytes", False)])] +TOKEN_ABI = ERC20_ABI + [event("Transfer", [("from", "address", True), ("to", "address", True), + ("value", "uint256", False)])] diff --git a/bridge-sdk/python/aleo_bridge/_cctp_message.py b/bridge-sdk/python/aleo_bridge/_cctp_message.py new file mode 100644 index 00000000..bf20dfda --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_cctp_message.py @@ -0,0 +1,76 @@ +"""CCTP V2 wire evidence. Offsets and hook frames pinned to Veil PR #148. + +_source: packages/bridge/src/protocols/cctp/evm.ts @ +3c3b457bd5f63620657321893a2487e489750d24. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .errors import AttestationError, ConfigurationError + +FORWARD_HOOK = b"cctp-forward".ljust(24, b"\0") + bytes(8) +LEGACY_FORWARD_HOOK = b"cctp-forward".ljust(24, b"\0") + (1).to_bytes(4, "big") + bytes(4) + + +def address_bytes(address: object) -> bytes: + if not isinstance(address, str) or not re.fullmatch(r"0x[0-9a-fA-F]{40}", address): + raise ConfigurationError("CCTP requires a 20-byte EVM address") + value = bytes.fromhex(address[2:]) + if value == bytes(20): + raise ConfigurationError("CCTP addresses must not be zero") + return value.rjust(32, b"\0") + + +@dataclass(frozen=True) +class CctpMessage: + raw: bytes + source_domain: int + destination_domain: int + nonce: bytes + messenger: bytes + destination_messenger: bytes + destination_caller: bytes + min_finality: int + finality: int + token: bytes + recipient: bytes + amount: int + sender: bytes + max_fee: int + fee: int + expiry: int + hook: bytes + + +def decode_message(raw: object) -> CctpMessage: + if not isinstance(raw, bytes) or len(raw) < 376: + raise AttestationError("CCTP message is truncated") + def uint(start: int, size: int) -> int: + return int.from_bytes(raw[start:start + size], "big") + if uint(0, 4) != 1 or uint(148, 4) != 1: + raise AttestationError("Unsupported CCTP message version") + return CctpMessage(raw, uint(4, 4), uint(8, 4), raw[12:44], raw[44:76], raw[76:108], raw[108:140], + uint(140, 4), uint(144, 4), raw[152:184], raw[184:216], uint(216, 32), + raw[248:280], uint(280, 32), uint(312, 32), uint(344, 32), raw[376:]) + + +def immutable_message(raw: bytes) -> bytes: + decode_message(raw) + return raw[0:12] + raw[44:144] + raw[148:312] + raw[376:] + + +def validate_message(raw: bytes, *, source_domain: int, destination_domain: int, messenger: str, + source_token: str, sender: str, recipient: str, amount_atomic: int, + max_fee_atomic: int, finality: int, forwarding: bool) -> CctpMessage: + m = decode_message(raw) + if (m.source_domain != source_domain or m.destination_domain != destination_domain + or m.messenger != address_bytes(messenger) or m.destination_messenger != address_bytes(messenger) + or m.destination_caller != bytes(32) or m.token != address_bytes(source_token) + or m.sender != address_bytes(sender) or m.recipient != address_bytes(recipient) + or m.amount != amount_atomic or m.max_fee != max_fee_atomic or m.max_fee >= m.amount + or m.min_finality != finality + or m.hook not in ((FORWARD_HOOK, LEGACY_FORWARD_HOOK) if forwarding else (b"",))): + raise AttestationError("CCTP source message does not match the transfer intent") + return m diff --git a/bridge-sdk/python/aleo_bridge/_evm_connections.py b/bridge-sdk/python/aleo_bridge/_evm_connections.py new file mode 100644 index 00000000..140416f2 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_evm_connections.py @@ -0,0 +1,66 @@ +"""Normalize explicitly named EVM providers without reading chains or writing keys.""" +from __future__ import annotations + +import os +from collections.abc import Mapping +from typing import Any, cast + +from .errors import ConfigurationError +from .eth import Ethereum +from .registry import Registry + +RPC_VARIABLES = {"arc": "ARC_RPC_URL", "base": "BASE_RPC_URL", "arbitrum": "ARBITRUM_RPC_URL"} + + +def coerce(value: Any) -> Ethereum: + if isinstance(value, Ethereum): + return value + if hasattr(value, "eth") and hasattr(value, "provider"): + return Ethereum(w3=value) + raise ConfigurationError("EVM connections must be Ethereum(...) or Web3 instances") + + +def normalize(registry: Registry, environment: str, connections: Mapping[str, Any] | None, + ethereum: Ethereum | None) -> dict[str, Ethereum]: + if connections is not None and not isinstance(cast(object, connections), Mapping): + raise ConfigurationError("evm= must map registry chain IDs to EVM connections") + out: dict[str, Ethereum] = {} + for key, value in (connections or {}).items(): + chain = registry.chain(key) + if chain.family != "evm" or chain.environment != environment: + raise ConfigurationError(f"EVM connection {key!r} must belong to {environment} and the EVM family") + conn = coerce(value) + if chain.id in out and out[chain.id] is not conn: + raise ConfigurationError(f"Conflicting EVM connections for {chain.id}") + out[chain.id] = conn + if ethereum is not None: + key = "ethereum" if environment == "mainnet" else "sepolia" + if key in out and out[key] is not ethereum: + raise ConfigurationError(f"Conflicting ethereum= and evm= connections for {key}") + out[key] = ethereum + return out + + +def from_env(overrides: Mapping[str, Any] | None = None, *, environment: str = "mainnet") -> dict[str, Any]: + out = {str(k).lower(): v for k, v in (overrides or {}).items()} + if environment != "mainnet": + return out + key = os.environ.get("EVM_PRIVATE_KEY") or os.environ.get("BRIDGE_EVM_PRIVATE_KEY") + floor = os.environ.get("BRIDGE_MIN_PRIORITY_FEE_WEI") + kwargs: dict[str, Any] = {} + if floor: + if not floor.isdigit(): + raise ConfigurationError("BRIDGE_MIN_PRIORITY_FEE_WEI must be a whole number of wei") + kwargs["min_priority_fee_wei"] = int(floor) + for chain, variable in RPC_VARIABLES.items(): + url = os.environ.get(variable) + if chain not in out and url: + out[chain] = Ethereum(url, private_key=key or None, **kwargs) + return out + + +def needs_legacy_environment(connections: Mapping[str, Any]) -> bool: + """Retain legacy pair validation unless another explicitly named EVM is configured.""" + if "ethereum" in connections or "sepolia" in connections: + return False + return not connections or bool(os.environ.get("ETHEREUM_RPC_URL") or os.environ.get("BRIDGE_LIVE_ETHEREUM_RPC_URL")) diff --git a/bridge-sdk/python/aleo_bridge/_plan.py b/bridge-sdk/python/aleo_bridge/_plan.py index 98b4610a..a2b14fd5 100644 --- a/bridge-sdk/python/aleo_bridge/_plan.py +++ b/bridge-sdk/python/aleo_bridge/_plan.py @@ -10,7 +10,7 @@ from .errors import BridgeError, UnsupportedRouteError from .registry import Asset, Registry, Route -from .types import Plan, Step +from .types import Plan, Step, CctpOptions, normalize_cctp from .units import format_decimal_amount WALLET_EXECUTOR_BY_FAMILY = {"evm": "evm-wallet", "solana": "solana-wallet", "aleo": "aleo-wallet"} @@ -26,7 +26,7 @@ def _wallet_executor(registry: Registry, source: Asset) -> str: def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipient: str, sender: str | None, - mint_mode: str = "public") -> Plan: + mint_mode: str = "public", cctp=None) -> 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) @@ -39,6 +39,9 @@ def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipien wallet = _wallet_executor(registry, source) source_family = registry.chain(source.chain_id).family destination_family = registry.chain(destination.chain_id).family + if cctp is not None and route.protocol != "cctp": + raise BridgeError("CCTP options only apply to CCTP routes") + options: CctpOptions | None = None if route.protocol == "xreserve": if source_family == "evm" and destination_family == "aleo": steps = (Step("source-approval", "approve", wallet, False), @@ -52,6 +55,14 @@ def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipien Step("destination-confirmation", "confirm-delivery", "protocol", False)) else: raise UnsupportedRouteError(f"Unsupported xReserve route direction: {route.id}") + elif route.protocol == "cctp": + if (source_family, destination_family) != ("evm", "evm"): + raise BridgeError("CCTP requires two EVM chains") + options = normalize_cctp(cctp) + steps = (Step("source-approval", "approve", wallet, False), + Step("source-burn", "burn", wallet, True), + Step("burn-attestation", "wait-attestation", "protocol", False), + Step("destination-mint", "mint", "protocol" if options.forwarding else "evm-wallet", False)) 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" @@ -62,7 +73,7 @@ def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipien 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) + recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps, cctp=options) __all__ = ["WALLET_EXECUTOR_BY_FAMILY", "build_plan"] diff --git a/bridge-sdk/python/aleo_bridge/_registry_compatibility.py b/bridge-sdk/python/aleo_bridge/_registry_compatibility.py new file mode 100644 index 00000000..6b6897a2 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_registry_compatibility.py @@ -0,0 +1,71 @@ +"""Accept old saved transfers only when their reviewed deployment remains identical.""" +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from .errors import BridgeError + +if TYPE_CHECKING: + from .registry import Registry + +LEGACY_VERSION = "2026-08-31.solana-deposits.1" + + +def route_fingerprint(registry: Registry, route_id: str) -> str: + route = registry.route(route_id) + assets = [registry.asset(route.source_asset_id), registry.asset(route.destination_asset_id)] + chains: list[dict[str, Any]] = [] + for asset in assets: + chain = asdict(registry.chain(asset.chain_id)) + domains = chain["protocol_domains"] + # Sepolia's original deployment used Ethereum's Circle domain; it is now explicit. + if chain["id"] == "sepolia" and route.protocol == "xreserve" and "xreserve" not in domains: + domains["xreserve"] = 0 + chain["protocol_domains"] = {route.protocol: domains[route.protocol]} if route.protocol in domains else {} + chains.append(chain) + snapshot = {"route": asdict(route), "assets": [asdict(a) for a in assets], "chains": chains} + return hashlib.sha256(json.dumps(snapshot, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode()).hexdigest() + + +def is_registry_version_compatible(registry: Registry, version: str | None, route_id: str | None) -> bool: + if route_id is None or version is None: + return False + if version == registry.version: + return True + if version != LEGACY_VERSION or route_id not in LEGACY_ROUTE_HASHES: + return False + try: + return route_fingerprint(registry, route_id) == LEGACY_ROUTE_HASHES[route_id] + except (BridgeError, TypeError, ValueError, KeyError): + return False + + +# Generated from the captured Python registry at 1c0935dbd6532319f0b5e3967320dbcbe0827a23. +LEGACY_ROUTE_HASHES: dict[str, str] = { + "xreserve:ethereum/usdc->aleo/usdcx": "4ed8a326a8fe3cb284648e17b9e319a1b484abc2013a85dd77d257120fd2276f", + "xreserve:aleo/usdcx->ethereum/usdc": "09da7909d1bf2cf378dc2554669b0245a400eb658b601ee2b3509389a91d2069", + "xreserve:sepolia/usdc->aleo-testnet/usdcx": "b0c21c2818c11fd0f44eb4b756f35f67eb269a0ae3561cceda9b13ef000701b0", + "xreserve:aleo-testnet/usdcx->sepolia/usdc": "f98f69e1c00b1b235aba49d1bfaf0dba293749141463683dedcf3ede3ab2c0fb", + "hyperlane:ethereum/eth->aleo/eth": "20373a6cd79a46cfa81f15c96afff141391b8d63c5590a1c66a39e952526e701", + "hyperlane:aleo/eth->ethereum/eth": "f8a90521d79b34e3a5e34608f0857c365d0a2dd7f0a4fd38bab87c0f201fd2fa", + "hyperlane:ethereum/wbtc->aleo/wbtc": "9ebcfd2052464d50952a7a0df297dac6c949839473f51afe733c3a32e6b55188", + "hyperlane:aleo/wbtc->ethereum/wbtc": "a815111fe419154f1dba77b34f49a491b59593811c38eb4b1e6439880bc050f9", + "hyperlane:ethereum/usdt->aleo/usdt": "17126cfffa855c9e56173290a196596b178753e61b3c00ec9ffe401bf4395770", + "hyperlane:aleo/usdt->ethereum/usdt": "40aeb94f3c007034cb2235f6f65fcd16a0a34d33e129d891b69d0c606b1e0b83", + "hyperlane:solana/sol->aleo/sol": "f8dccf00884bf41dc4ae702ec40366dacc47c65c2aa5985e7638ad11139098fc", + "hyperlane:aleo/sol->solana/sol": "ae205673cb91518ec0e6aeea2a465bd15450e880809ee642226640690e461f80", + "hyperlane:aleo/aleo->ethereum/aleo": "c940558ced6095dae81a41ab135ae0dece301d753321bf5530052c3927a91e24", + "hyperlane:ethereum/aleo->aleo/aleo": "748b567a881f9de2d5802cb02d23e699dbd47fb17966522260cfce17f83fe4e4", + "hyperlane:aleo/aleo->solana/aleo": "71a29407848f09605d8d611f678a0531803a40e982f9976c85bae4fa775e7075", + "hyperlane:solana/aleo->aleo/aleo": "32a57bb81118f958ead32837eaed1c3252042a614ef147537c165c1bcf7847fc", + "hyperlane:aleo/aleo->base/aleo": "622ad27af392b5992ca2083d1c9b73cff768b3b3c5630391bd1be582ccf3ff8d", + "hyperlane:base/aleo->aleo/aleo": "01f12d5cc78e7d734a370be742f131eeb7955e4bfce5fc3f0ceb4ed5c144ec6a", + "hyperlane:aleo/aleo->hyperevm/aleo": "f4035f8b63001f7dacb78ef7e0cf4f5b3ecbc4c426ff1455f91aca01dd277118", + "hyperlane:hyperevm/aleo->aleo/aleo": "317af78d3ae408f373da6dba0aa561183ce2b854e4b0a1f9424247ef905578a7", + "hyperlane:ethereum/usad->aleo/usad": "98f6bfba28a1011526f5ec58b8e30ce6526363502ff333fd218c3d8e8375e7eb", + "hyperlane:aleo/usad->ethereum/usad": "a23c9db907e33e2d5da5bfbf9061c616a9ac4d9e5bc05495e02c05d4a013efe8" +} diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py index 3bf6c1ea..3a682bbe 100644 --- a/bridge-sdk/python/aleo_bridge/_registry_data.py +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -3,7 +3,7 @@ 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" +REGISTRY_VERSION = "2026-09-28.cctp-arc.1" EVM_ADDRESS = "^0x[0-9a-fA-F]{40}$" SOLANA_ADDRESS = "^[1-9A-HJ-NP-Za-km-z]{32,44}$" @@ -13,15 +13,20 @@ {"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}}, + "protocolDomains": {"xreserve": 0, "hyperlane": 1, "cctp": 0}}, + {"id": "arc", "displayName": "Arc", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "USDC", + "protocolDomains": {"xreserve": 26, "cctp": 26}}, {"id": "solana", "displayName": "Solana", "family": "solana", "environment": "mainnet", "nativeCurrencySymbol": "SOL", "protocolDomains": {"hyperlane": 1399811149}}, - {"id": "base", "displayName": "Base", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH"}, + {"id": "base", "displayName": "Base", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"cctp": 6}}, + {"id": "arbitrum", "displayName": "Arbitrum", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"cctp": 3}}, {"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}}, + "protocolDomains": {"hyperlane": 11155111, "xreserve": 0}}, ] ASSETS = [ @@ -75,6 +80,25 @@ "locator": {"kind": "evm-contract", "value": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}, "addressValidationRegex": EVM_ADDRESS}, ] +# _source: ProvableHQ/veil packages/bridge/src/registry/default.ts @ +# 3c3b457bd5f63620657321893a2487e489750d24 (PR #148). +for _chain, _token in ( + ("arc", "0x3600000000000000000000000000000000000000"), + ("base", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + ("arbitrum", "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"), +): + ASSETS.append({"id": f"{_chain}/usdc", "key": "usdc", "chainId": _chain, "symbol": "USDC", + "name": "USD Coin", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": _token}, "addressValidationRegex": EVM_ADDRESS}) + +CCTP_SOURCE = "https://developers.circle.com/cctp/references/contract-addresses" +CCTP_MAINNET_METADATA = { + "tokenMessenger": "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", + "messageTransmitter": "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64", + "attestationBaseUrl": "https://iris-api.circle.com", + "deploymentReviewedAt": "2026-09-29", + "tokenSource": "https://developers.circle.com/stablecoins/usdc-contract-addresses", +} 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" @@ -332,6 +356,17 @@ def _aleo_hyperlane_placeholders(program: str, destination_domain: int) -> dict: "attestationBaseUrl": "https://xreserve-api-testnet.circle.com/v1/attestations", } +XRESERVE_ARC_METADATA = { + **XRESERVE_MAINNET_METADATA, + "sourceChainId": 5042, "sourceDomain": 26, + "minimumBurnAmountAtomic": "2000000", "withdrawalFeeAtomic": "16400", + "withdrawalFeeUrl": "https://api.usdcx.aleo.org/api/estimate-burn-fee", + "withdrawalFeeChain": "arc", + "withdrawalFeeSource": "https://usdcx.aleo.org/assets/index-C4YEghH3.js", + "deploymentSource": "https://docs.aleo.org/build/common-uses/usdcx_bridge", + "onchainReviewedAt": "2026-09-28", +} + def _route(id: str, protocol: str, environment: str, source_asset_id: str, destination_asset_id: str, availability: str, deployment_id: str, metadata: dict) -> dict: @@ -339,7 +374,7 @@ def _route(id: str, protocol: str, environment: str, source_asset_id: str, desti "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, + "source": XRESERVE_SOURCE if protocol == "xreserve" else CCTP_SOURCE if protocol == "cctp" else HYPERLANE_SOURCE, "metadata": dict(metadata), } @@ -384,3 +419,14 @@ def _pair(protocol: str, environment: str, left: str, right: str, availability: _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)), ] + +ROUTES.extend(_pair("xreserve", "mainnet", "arc/usdc", "aleo/usdcx", "active", + "xreserve-usdcx-aleo-arc", XRESERVE_ARC_METADATA)) +for _chain, _chain_id, _domain in (("ethereum", 1, 0), ("base", 8453, 6), ("arbitrum", 42161, 3)): + for _src, _dst, _sid, _did, _sd, _dd in ( + (_chain, "arc", _chain_id, 5042, _domain, 26), ("arc", _chain, 5042, _chain_id, 26, _domain), + ): + ROUTES.append(_route(f"cctp:{_src}/usdc->{_dst}/usdc", "cctp", "mainnet", f"{_src}/usdc", + f"{_dst}/usdc", "active", f"cctp-v2-{_src}-{_dst}", + {**CCTP_MAINNET_METADATA, "sourceChainId": _sid, "destinationChainId": _did, + "sourceDomain": _sd, "destinationDomain": _dd})) diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index c89379ed..8fd2d359 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -12,7 +12,7 @@ import hashlib import re from dataclasses import dataclass -from typing import Mapping, Sequence +from typing import Any, Mapping, Sequence from ._base58 import b58decode, b58encode from .errors import BridgeError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteUnavailableError @@ -235,7 +235,7 @@ def pubkey(key: str) -> str: raise RouteUnavailableError(f"Solana Hyperlane route has an invalid {key}: {route.id}") return value - fields = {attr: pubkey(key) for key, attr in _PUBKEY_FIELDS} + fields: dict[str, Any] = {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") diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index fdc816fe..6a6df2ea 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -62,7 +62,7 @@ #: 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", + "payload", "message", "attestation", "secretnonce", "record", "recordplaintext", "privatekey", "hookdata", "preparedtransaction", "prepareddestinationtransaction", }) @@ -211,7 +211,10 @@ def _missing_nonce(what: str) -> dict[str, Any]: "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."}, - "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane"], "description": "Only needed when both protocols serve the pair."}, + "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane", "cctp"], "description": "Only needed when multiple protocols serve the pair."}, + "cctp": {"type": "object", "additionalProperties": False, "properties": { + "speed": {**_S, "enum": ["standard", "fast"]}, "forwarding": _B, + "max_fee": {**_S, "description": "Maximum USDC deduction, preserved through recovery and fee refresh."}}}, "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' " @@ -223,6 +226,9 @@ def _missing_nonce(what: str) -> dict[str, Any]: "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')."}} +_REPLACEMENT = {"approval_replacement": {"type": "object", "additionalProperties": False, + "properties": {"original_transaction_id": _S, "replacement_transaction_id": _S}, + "required": ["original_transaction_id", "replacement_transaction_id"]}} def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: @@ -234,7 +240,8 @@ def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: destination_chain=args["destination_chain"], destination_asset=args.get("destination_asset"), bridge_protocol=args.get("bridge_protocol"), amount=str(args["amount"]), recipient=args["recipient"], sender=args.get("sender"), mint_mode=mint_mode, - secret_nonce=secret_nonce if mint_mode == "private" else (secret_nonce or "0scalar")) + secret_nonce=secret_nonce if mint_mode == "private" else (secret_nonce or "0scalar"), + **({"cctp": args["cctp"]} if "cctp" in args else {})) def _private_nonce_gap(args: dict[str, Any]) -> dict[str, Any] | None: @@ -268,6 +275,8 @@ def _write(b: Any, call: Callable[[list[Any]], Any]) -> dict[str, Any]: payload["how_to_fix"] = RECOVER_HOW_TO_FIX if seen: payload["checkpoint"] = _serialize(seen[-1], b.registry) + elif getattr(exc, "checkpoint", None) is not None: + payload["checkpoint"] = _serialize(getattr(exc, "checkpoint"), b.registry) return payload return _with_checkpoint(b, progress) @@ -302,7 +311,8 @@ def _h_quote(b, a): 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"])) + options = {"approval_replacement": a["approval_replacement"]} if "approval_replacement" in a else {} + return _with_checkpoint(b, lifecycle.recover(b, a["checkpoint"], **options)) def _h_pending(b, a): @@ -326,7 +336,7 @@ def _h_pending(b, a): return [] loader = getattr(store, "load_checkpoints", None) if callable(loader): - result = loader() + result: Any = loader() checkpoints, problems = result.checkpoints, result.errors else: checkpoints, problems = store.list(), [] @@ -395,12 +405,13 @@ def _has_prepared_destination(progress: Any) -> bool: 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): + if progress.plan.protocol != "cctp" and 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)) + options = {"manual_mint": a["manual_mint"]} if "manual_mint" in a else {} return _write(b, lambda seen: lifecycle.complete(b, progress, on_checkpoint=seen.append, - secret_nonce=secret_nonce)) + secret_nonce=secret_nonce, **options)) def _privacy(b, a, direction: str): @@ -447,7 +458,7 @@ def _h_unshield(b, a): "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), + _schema({**_CHECKPOINT, **_REPLACEMENT}, ["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 " @@ -473,9 +484,9 @@ def _h_unshield(b, a): "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, + "Complete a private USDCx or CCTP mint. Private USDCx requires the original secret_nonce. " + "CCTP forwarding fallback requires manual_mint=true. Requires confirm=true.", + _schema({**_CHECKPOINT, "manual_mint": {**_B, "description": "Explicitly mint a stalled CCTP forwarded transfer."}, "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."}, diff --git a/bridge-sdk/python/aleo_bridge/cctp.py b/bridge-sdk/python/aleo_bridge/cctp.py new file mode 100644 index 00000000..4bc6c715 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/cctp.py @@ -0,0 +1,655 @@ +"""Move native USDC between Arc and Ethereum, Base, or Arbitrum through Circle CCTP V2. + +_source: ProvableHQ/veil packages/bridge/src/protocols/cctp/evm.ts @ +3c3b457bd5f63620657321893a2487e489750d24. Network effects stay behind lifecycle verbs. +""" +from __future__ import annotations + +import re +import time +from dataclasses import dataclass, replace +from typing import Any, TYPE_CHECKING, cast +from collections.abc import Callable, Iterator, Iterable + +import requests + +from ._cctp_message import address_bytes, validate_message, immutable_message, FORWARD_HOOK, CctpMessage +from ._cctp_abi import MESSENGER_ABI, TRANSMITTER_ABI, TOKEN_ABI +from .errors import AttestationError, ConfigurationError, InvalidAmountError, BridgeError +from .registry import Route +from .types import CctpOptions, EvmCctpQuote, Fee, Plan, Receipt, Status, TERMINAL, normalize_cctp +from .units import format_decimal_amount, parse_decimal_amount + +if TYPE_CHECKING: + from .checkpoint import Checkpoint + from .eth import Ethereum + + +@dataclass(frozen=True) +class Metadata: + route: Route + source_chain: str + destination_chain: str + source_chain_id: int + destination_chain_id: int + source_domain: int + destination_domain: int + messenger: str + transmitter: str + source_token: str + destination_token: str + attestation_url: str + + +class _FeeCapExceeded(InvalidAmountError): + """Current fees exceed a fixed quote; no burn may be submitted.""" + + +class CctpCheckpointError(BridgeError): + """A transaction may be submitted; recover using ``checkpoint``, never execute again.""" + + def __init__(self, tx_hash: str, checkpoint: Checkpoint) -> None: + super().__init__(f"CCTP transaction {tx_hash} was submitted but checkpoint notification failed; recover the attached checkpoint") + self.broadcast_id = tx_hash + self.checkpoint = checkpoint + + +def _uint(value: Any, field: str, maximum: int = 2**256 - 1) -> int: + if (isinstance(value, bool) or not isinstance(value, (int, str)) + or not re.fullmatch(r"[0-9]+", str(value)) or int(value) > maximum): + raise ConfigurationError(f"Invalid CCTP {field}") + return int(value) + + +def _basis_points(amount: int, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, (int, str, float)) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", str(value)): + raise AttestationError("Invalid Circle minimumFee") + whole, _, fraction = str(value).partition(".") + denominator = 10 ** len(fraction) * 10_000 + return (amount * int(whole + fraction) + denominator - 1) // denominator + + +@dataclass +class _ApprovalScan: + head: int + head_hash: str + next_block: int + found: set[str] + + +class CctpModule: + """Quote CCTP fees and track delivery through the bridge's shared lifecycle.""" + + def __init__(self, bridge: Any) -> None: + self.bridge = bridge + self.circle_session: Any = None + # Process-local progress only: checkpoint-supplied cursors must never authorize + # skipping history. A new process safely starts reconciliation from the beginning. + self._approval_scans: dict[tuple[int, str, tuple[str, ...]], _ApprovalScan] = {} + self._delivery_scans: dict[tuple[int, str, bytes], tuple[int, str, int]] = {} + + def _metadata(self, plan: Plan) -> Metadata: + from .lifecycle import resolve_route + resolved = resolve_route(self.bridge.registry, plan) + route, src, dst = resolved.route, resolved.source_asset, resolved.destination_asset + if plan.protocol != "cctp" or not route.active or plan.environment != self.bridge.environment: + raise ConfigurationError("CCTP plan does not match an active route in this environment") + for asset, chain in ((src, resolved.source_chain), (dst, resolved.destination_chain)): + if (chain.family != "evm" or asset.key != "usdc" or asset.decimals != 6 + or asset.locator is None or asset.locator.kind != "evm-contract"): + raise ConfigurationError("CCTP requires canonical six-decimal EVM USDC") + address_bytes(asset.locator.value) + address_bytes(plan.recipient) + if plan.sender is not None: + address_bytes(plan.sender) + amount = parse_decimal_amount(plan.amount, 6) + if type(plan.amount_atomic) is not int or amount != plan.amount_atomic or not 0 < amount < 2**256: + raise InvalidAmountError("CCTP amount must be a matching positive uint256") + if plan.mint_mode != "public": + raise ConfigurationError("CCTP does not support Aleo mint modes") + options = normalize_cctp(plan.cctp) + if options.max_fee is not None and parse_decimal_amount(options.max_fee, 6) >= amount: + raise InvalidAmountError("CCTP max_fee must be less than the burn amount") + for chain, key in ((resolved.source_chain, "sourceDomain"), (resolved.destination_chain, "destinationDomain")): + domain = chain.protocol_domains.get("cctp") + if type(domain) is not int or route.meta_int(key) != domain: + raise ConfigurationError("CCTP route domains must match configured chain domains") + messenger, transmitter = route.meta_str("tokenMessenger"), route.meta_str("messageTransmitter") + address_bytes(messenger) + address_bytes(transmitter) + url = route.meta_str("attestationBaseUrl") + if not url.startswith("https://"): + raise ConfigurationError("CCTP attestation URL must use HTTPS") + assert src.locator is not None and dst.locator is not None + return Metadata(route, src.chain_id, dst.chain_id, + _uint(route.meta_int("sourceChainId"), "source chain", 2**32 - 1), + _uint(route.meta_int("destinationChainId"), "destination chain", 2**32 - 1), + route.meta_int("sourceDomain"), route.meta_int("destinationDomain"), + messenger, transmitter, src.locator.value, dst.locator.value, url.rstrip("/")) + + def _json(self, url: str) -> Any: + if self.circle_session is None: + self.circle_session = requests.Session() + try: + response = self.circle_session.get(url, timeout=30) + if response.status_code == 404: + return None + if response.status_code != 200: + raise AttestationError(f"Circle CCTP API returned HTTP status {response.status_code}") + return response.json() + except (requests.RequestException, ValueError) as exc: + raise AttestationError("Circle CCTP request failed") from exc + + def quote(self, plan: Plan) -> EvmCctpQuote: + """Read current CCTP fees and fix the maximum USDC deduction without signing.""" + m = self._metadata(plan) + options = normalize_cctp(plan.cctp) + finality = 1000 if options.speed == "fast" else 2000 + suffix = "?forward=true" if options.forwarding else "" + response = self._json(f"{m.attestation_url}/v2/burn/USDC/fees/{m.source_domain}/{m.destination_domain}{suffix}") + if not isinstance(response, list): + raise AttestationError("Circle returned invalid CCTP fees") + entries = [cast(dict[str, Any], f) for f in cast(list[Any], response) if isinstance(f, dict)] + matches = [f for f in entries if type(f.get("finalityThreshold")) is int + and f["finalityThreshold"] == finality] + if len(matches) != 1: + raise AttestationError("Circle does not uniquely quote the requested CCTP finality") + fee = matches[0] + protocol = _basis_points(plan.amount_atomic, fee.get("minimumFee")) + forwarding = 0 + if options.forwarding: + forward = fee.get("forwardFee") + if not isinstance(forward, dict): + raise AttestationError("Invalid Circle forwarding fee") + forward = cast(dict[str, Any], forward) + forwarding = _uint(forward.get("medium", forward.get("med")), "forwarding fee") + required = protocol + forwarding + # Ten percent headroom, rounded up in USDC atomic units, is visible in + # the quoted ceiling. Never grow an explicit or checkpointed budget. + cap = (min(plan.amount_atomic - 1, required + (required + 9) // 10) + if options.max_fee is None else parse_decimal_amount(options.max_fee, 6)) + if required > cap: + raise _FeeCapExceeded("Live CCTP fees exceed the approved max_fee; resume the saved transfer when fees fall within its cap") + if cap >= plan.amount_atomic: + raise InvalidAmountError("CCTP fees must be less than the burn amount") + receive = plan.amount_atomic - (cap if options.forwarding else required) + plan = replace(plan, cctp=CctpOptions(options.speed, options.forwarding, format_decimal_amount(cap, 6))) + fees = [Fee("protocol", m.destination_chain, plan.destination_asset_id, format_decimal_amount(protocol, 6), True)] + if options.forwarding: + fees.append(Fee("relayer", m.destination_chain, plan.destination_asset_id, + format_decimal_amount(forwarding, 6), True)) + return EvmCctpQuote("evm-cctp", plan, tuple(fees), format_decimal_amount(receive, 6), plan.amount_atomic, + receive, protocol, forwarding, cap, finality, options.forwarding) + + def _connection(self, chain: str, expected: int) -> Ethereum: + conn: Ethereum = self.bridge.evm(chain).conn + if conn.chain_id != expected: + raise ConfigurationError(f"CCTP transport must use EVM chain {expected}") + return conn + + @staticmethod + def _contract(conn: Ethereum, address: str, abi: list[dict[str, Any]]) -> Any: + from web3 import Web3 + return conn.w3.eth.contract(address=Web3.to_checksum_address(address), abi=abi) + + @staticmethod + def _hash(value: Any) -> str: + if not isinstance(value, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", value): + raise ConfigurationError("Invalid CCTP transaction hash") + return value + + @staticmethod + def _hex(value: Any) -> str: + from web3 import Web3 + return Web3.to_hex(value) if not isinstance(value, str) else value + + def _success(self, mined: Any, tx_hash: str) -> None: + if self._hex(mined["transactionHash"]).lower() != tx_hash.lower() or mined["status"] != 1: + raise BridgeError("CCTP transaction reverted or receipt hash differs") + + def _confirm(self, conn: Ethereum, tx_hash: str, interval: float, timeout: float) -> Any: + deadline = time.monotonic() + timeout + while True: + mined = conn.get_receipt(tx_hash) + if mined is not None: + self._success(mined, tx_hash) + return mined + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + time.sleep(min(max(interval, 0.01), remaining)) + + def _broadcast(self, conn: Ethereum, tx: dict[str, Any], receipt: Receipt, + emit: Callable[[Receipt], Any], *, plan: Plan, approval: bool = False, destination: bool = False) -> Receipt: + def submitted(tx_hash: str) -> Receipt: + self._hash(tx_hash) + state = dict(receipt.protocol_state) + if approval: + state["approvalTxIds"] = [*state.get("approvalTxIds", []), tx_hash] + return receipt.replace(id=tx_hash, status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state) + if destination: + return receipt.replace(destination_tx_id=tx_hash, status=Status.DESTINATION_CONFIRMING, next_action=None) + if conn.last_broadcast_nonce is not None: + state["sourceNonce"] = str(conn.last_broadcast_nonce) + return receipt.replace(id=tx_hash, source_tx_id=tx_hash, status=Status.SOURCE_CONFIRMING, protocol_state=state) + def persist(state: Receipt, tx_hash: str) -> None: + from .checkpoint import create_checkpoint + checkpoint = create_checkpoint(plan, state, self.bridge.registry) + try: + # Save the transaction independently of user notification. A callback + # failure must never leave only an earlier approval in the journal. + store = getattr(self.bridge, "checkpoints", None) + if store is not None: + store.save(checkpoint) + emit(state) + except Exception as exc: + raise CctpCheckpointError(tx_hash, checkpoint) from exc + try: + tx_hash = conn.send_transaction(tx) + except BridgeError as exc: + if getattr(exc, "broadcast_id", None): + tx_hash = self._hash(getattr(exc, "broadcast_id")) + persist(submitted(tx_hash), tx_hash) + raise + state = submitted(tx_hash) + persist(state, tx_hash) + return state + + def _approvals_confirmed(self, conn: Ethereum, m: Metadata, plan: Plan, receipt: Receipt) -> bool: + from web3.exceptions import TransactionNotFound + from eth_abi.abi import decode + from eth_utils.crypto import keccak + values = receipt.protocol_state.get("approvalTxIds") + if not isinstance(values, list): + raise ConfigurationError("Invalid CCTP approval checkpoint") + sender = plan.sender or receipt.protocol_state.get("sourceSender") + if not isinstance(sender, str): + raise ConfigurationError("CCTP recovery requires the source sender") + address_bytes(sender) + for value in cast(list[Any], values): + tx_hash = self._hash(value) + try: + tx = conn.w3.eth.get_transaction(tx_hash) + except TransactionNotFound: + return False + mined = conn.get_receipt(tx_hash) + if mined is None: + return False + self._success(mined, tx_hash) + if (self._hex(tx["hash"]).lower() != tx_hash.lower() or tx["from"].lower() != sender.lower() + or (tx.get("to") or "").lower() != m.source_token.lower() or tx.get("value", 0) != 0): + raise ConfigurationError("CCTP approval does not match the source account and token") + raw = bytes(tx["input"]) + if len(raw) != 68 or raw[:4] != keccak(text="approve(address,uint256)")[:4]: + raise ConfigurationError("CCTP approval calldata is invalid") + spender, amount = decode(["address", "uint256"], raw[4:]) + if spender.lower() != m.messenger.lower() or amount != plan.amount_atomic: + raise ConfigurationError("CCTP approval does not match the planned allowance") + return True + + def execute(self, plan: Plan, *, on_checkpoint: Callable[[Receipt], Any], poll_seconds: float = 1.0, + timeout_seconds: float = 120.0, resume: Receipt | None = None) -> Receipt: + from web3 import Web3 + m = self._metadata(plan) + if resume is not None and resume.source_tx_id: + return self.get_status(plan, resume) + conn = self._connection(m.source_chain, m.source_chain_id) + sender = conn.require_address() + if plan.sender and sender.lower() != plan.sender.lower(): + raise ConfigurationError("CCTP source wallet differs from the planned sender") + if resume and sender.lower() != str(resume.protocol_state.get("sourceSender")).lower(): + raise ConfigurationError("CCTP resumed wallet differs from checkpoint sender") + state = resume or Receipt(plan.route_id, "cctp", Status.PREPARED, + protocol_state={"routeId": plan.route_id, "sourceSender": sender, "approvalTxIds": []}) + if resume and not self._approvals_confirmed(conn, m, plan, state): + return state.replace(status=Status.SOURCE_APPROVAL_PENDING) + if resume: + reconciled = self.get_status(plan, state) + if reconciled.status != Status.SOURCE_SUBMISSION_PENDING: + return reconciled + state = reconciled + try: + priced = self.quote(replace(plan, sender=sender)) + except _FeeCapExceeded as exc: + if resume is None: + raise + return state.replace(status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={**state.protocol_state, "sourceError": str(exc)}) + state = state.replace(protocol_state={k: v for k, v in state.protocol_state.items() if k != "sourceError"}) + token = self._contract(conn, m.source_token, TOKEN_ABI) + if token.functions.balanceOf(sender).call() < plan.amount_atomic: + raise BridgeError("Insufficient source USDC balance for CCTP burn") + if conn.w3.eth.get_balance(sender) <= 0: + raise BridgeError("Source wallet requires native gas funds for CCTP approval and burn") + if token.functions.allowance(sender, Web3.to_checksum_address(m.messenger)).call() < plan.amount_atomic: + data = token.encode_abi("approve", args=[Web3.to_checksum_address(m.messenger), plan.amount_atomic]) + state = self._broadcast(conn, {"to": token.address, "data": data}, state, on_checkpoint, plan=priced.plan, approval=True) + if not self._confirm(conn, state.id, poll_seconds, timeout_seconds): + return state + try: + priced = self.quote(priced.plan) + except _FeeCapExceeded as exc: + if not state.protocol_state.get("approvalTxIds"): + raise + return state.replace(status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={**state.protocol_state, "sourceError": str(exc)}) + args: list[Any] = [plan.amount_atomic, m.destination_domain, address_bytes(plan.recipient), + Web3.to_checksum_address(m.source_token), bytes(32), priced.max_fee_atomic, priced.min_finality_threshold] + function = "depositForBurn" + if priced.forwarding: + args.append(FORWARD_HOOK) + function += "WithHook" + messenger = self._contract(conn, m.messenger, MESSENGER_ABI) + state = self._broadcast(conn, {"to": messenger.address, "data": messenger.encode_abi(function, args=args)}, + state, on_checkpoint, plan=priced.plan) + if not self._confirm(conn, self._hash(state.source_tx_id), poll_seconds, timeout_seconds): + return state + return self.get_status(priced.plan, state) + + def _check_message(self, raw: bytes, m: Metadata, plan: Plan, sender: str) -> CctpMessage: + options = normalize_cctp(plan.cctp) + if options.max_fee is None: + raise ConfigurationError("CCTP verification requires the approved fee cap") + return validate_message(raw, source_domain=m.source_domain, destination_domain=m.destination_domain, + messenger=m.messenger, source_token=m.source_token, sender=sender, recipient=plan.recipient, + amount_atomic=plan.amount_atomic, max_fee_atomic=parse_decimal_amount(options.max_fee, 6), + finality=1000 if options.speed == "fast" else 2000, forwarding=options.forwarding) + + def _events(self, conn: Ethereum, address: str, abi: list[dict[str, Any]], name: str, + logs: Iterable[Any]) -> Iterator[tuple[dict[str, Any], Any]]: + event = getattr(self._contract(conn, address, abi).events, name)() + for log in logs: + if log["address"].lower() != address.lower(): + continue + try: + decoded = event.process_log(log) + except Exception: + # Unrelated logs have diverse Web3/eth-abi decoding errors. + # This boundary includes no RPC or other network operations. + continue + yield decoded["args"], log + + def _destination_matches(self, conn: Ethereum, mined: Any, m: Metadata, + message: CctpMessage, recipient: str) -> bool: + received = any(e["sourceDomain"] == m.source_domain and bytes(e["nonce"]) == message.nonce + and bytes(e["sender"]) == message.messenger and bytes(e["messageBody"]) == message.raw[148:] + and e["finalityThresholdExecuted"] == message.finality + for e, _ in self._events(conn, m.transmitter, TRANSMITTER_ABI, "MessageReceived", mined["logs"])) + minted = any(int(e["from"], 16) == 0 and e["to"].lower() == recipient.lower() + and e["value"] == message.amount - message.fee + for e, _ in self._events(conn, m.destination_token, TOKEN_ABI, "Transfer", mined["logs"])) + return received and minted + + def get_status(self, plan: Plan, receipt: Receipt) -> Receipt: + from web3 import Web3 + from eth_utils.crypto import keccak + m = self._metadata(plan) + if receipt.protocol != "cctp" or receipt.protocol_state.get("routeId") != plan.route_id: + raise ConfigurationError("CCTP receipt belongs to a different route") + if receipt.status in TERMINAL: + return receipt + source = self._connection(m.source_chain, m.source_chain_id) + if not receipt.source_tx_id: + confirmed = self._approvals_confirmed(source, m, plan, receipt) + if not confirmed: + return receipt.replace(status=Status.SOURCE_APPROVAL_PENDING) + return self._reconcile_approval(source, m, plan, receipt) + tx_hash = self._hash(receipt.source_tx_id) + mined = source.get_receipt(tx_hash) + if mined is None: + return receipt.replace(status=Status.SOURCE_CONFIRMING) + if mined["status"] == 0: + return receipt.replace(status=Status.FAILED, protocol_state={**receipt.protocol_state, "error": "CCTP source burn reverted"}) + self._success(mined, tx_hash) + sender = plan.sender or receipt.protocol_state.get("sourceSender") + if not isinstance(sender, str): + raise ConfigurationError("CCTP verification requires the source sender") + address_bytes(sender) + messages: list[CctpMessage] = [] + for event, _ in self._events(source, m.transmitter, TRANSMITTER_ABI, "MessageSent", mined["logs"]): + try: + messages.append(self._check_message(bytes(event["message"]), m, plan, sender)) + except AttestationError: + continue + if len(messages) != 1: + raise AttestationError("Source receipt must contain exactly one CCTP message matching the intent") + response = self._json(f"{m.attestation_url}/v2/messages/{m.source_domain}?transactionHash={tx_hash}") + response = cast(dict[str, Any], response) if isinstance(response, dict) else {} + if "sourceTxHash" in response and str(response["sourceTxHash"]).lower() != tx_hash.lower(): + raise AttestationError("Circle returned messages for a different source transaction") + candidates: list[tuple[dict[str, Any], bytes]] = [] + entries: Any = response.get("messages", []) + for entry in cast(list[Any], entries) if isinstance(entries, list) else []: + if not isinstance(entry, dict): + continue + entry = cast(dict[str, Any], entry) + try: + raw = bytes.fromhex(entry["message"][2:]) + if not entry["message"].startswith("0x") or immutable_message(raw) != immutable_message(messages[0].raw): + continue + except (ValueError, KeyError, TypeError, AttestationError): + continue + candidates.append((entry, raw)) + if len(candidates) > 1: + raise AttestationError("Circle returned ambiguous CCTP messages") + if not candidates: + return receipt.replace(status=Status.ATTESTATION_PENDING) + entry, raw = candidates[0] + if entry.get("status") != "complete" or not isinstance(entry.get("attestation"), str) or not re.fullmatch(r"0x(?:[0-9a-fA-F]{2})+", entry["attestation"]): + return receipt.replace(status=Status.ATTESTATION_PENDING) + message = self._check_message(raw, m, plan, sender) + if message.nonce == bytes(32) or message.finality < message.min_finality or message.fee > message.max_fee: + raise AttestationError("Invalid Circle CCTP attested nonce, finality, or fee") + dest = self._connection(m.destination_chain, m.destination_chain_id) + used = self._contract(dest, m.transmitter, TRANSMITTER_ABI).functions.usedNonces(message.nonce).call() != 0 + state = {**receipt.protocol_state, "message": "0x"+raw.hex(), "attestation": entry["attestation"], + "nonce": "0x"+message.nonce.hex(), "nonceUsed": used, "sourceSender": sender} + receipt = receipt.replace(protocol_state=state) + forwarded = entry.get("forwardTxHash") + dest_hash = receipt.destination_tx_id or (forwarded if isinstance(forwarded, str) and re.fullmatch(r"0x[0-9a-fA-F]{64}", forwarded) else None) + if used and not dest_hash: + head = dest.w3.eth.block_number + key = (id(dest), m.transmitter.lower(), message.nonce) + prior = self._delivery_scans.get(key) + if prior is not None: + anchor, anchor_hash, end = prior + if head < anchor or self._hex(dest.w3.eth.get_block(anchor)["hash"]) != anchor_hash: + self._delivery_scans.pop(key, None) + raise ConfigurationError("CCTP destination head block changed during recovery; retry") + else: + anchor, end = head, head + anchor_hash = self._hex(dest.w3.eth.get_block(anchor)["hash"]) + floor = max(0, end-9999) + topics = ["0x"+keccak(text="MessageReceived(address,uint32,bytes32,bytes32,uint32,bytes)").hex(), None, "0x"+message.nonce.hex()] + while end >= floor: + start = max(floor, end-999) + logs = dest.w3.eth.get_logs({"address": Web3.to_checksum_address(m.transmitter), "topics": topics, + "fromBlock": start, "toBlock": end}) + for event, log in self._events(dest, m.transmitter, TRANSMITTER_ABI, "MessageReceived", logs): + if bytes(event["nonce"]) == message.nonce and event["sourceDomain"] == m.source_domain: + dest_hash = self._hash(self._hex(log["transactionHash"])) + break + if dest_hash: + break + end = start-1 + self._delivery_scans[key] = (anchor, anchor_hash, end) + if self._hex(dest.w3.eth.get_block(anchor)["hash"]) != anchor_hash: + self._delivery_scans.pop(key, None) + raise ConfigurationError("CCTP destination head block changed during recovery; retry") + if dest_hash or end < 0: + self._delivery_scans.pop(key, None) + failed = False + if dest_hash: + delivered = dest.get_receipt(self._hash(dest_hash)) + if delivered is None: + return receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=dest_hash, next_action=None) + if delivered["status"] == 1: + self._success(delivered, dest_hash) + if not self._destination_matches(dest, delivered, m, message, plan.recipient): + raise AttestationError("CCTP destination receipt does not prove the expected USDC mint") + return receipt.replace(status=Status.COMPLETED if used else Status.DESTINATION_CONFIRMING, + destination_tx_id=dest_hash, next_action=None) + failed = True + receipt = receipt.replace(destination_tx_id=None, protocol_state={**state, "forwardingFailed": True}) + if used or (normalize_cctp(plan.cctp).forwarding and not failed): + return receipt.replace(status=Status.DELIVERY_PENDING, next_action=None) + return receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "cctp-mint", "chainId": m.destination_chain}) + + def _reconcile_approval(self, conn: Ethereum, m: Metadata, plan: Plan, receipt: Receipt) -> Receipt: + """A stale approval is not authority to repeat a later burn. Reconcile mined history + and refuse an unaccounted-for source nonce before offering a resumption.""" + from eth_utils.crypto import keccak + from web3 import Web3 + approvals = receipt.protocol_state["approvalTxIds"] + if not approvals: + raise ConfigurationError("CCTP recovery needs a submitted approval or burn") + mined = [conn.get_receipt(h) for h in approvals] + start = min(int(r["blockNumber"]) for r in mined if r is not None) + head = conn.w3.eth.block_number + if head < start: + raise ConfigurationError("CCTP RPC head precedes the confirmed approval; retry recovery on a consistent provider") + key = (id(conn), repr(plan), tuple(approvals)) + scan = self._approval_scans.get(key) + if scan is not None: + if head < scan.head or self._hex(conn.w3.eth.get_block(scan.head)["hash"]) != scan.head_hash: + self._approval_scans.pop(key, None) + raise ConfigurationError("CCTP head block changed during recovery; retry on a consistent provider") + if scan.next_block > scan.head: + new_hash = self._hex(conn.w3.eth.get_block(head)["hash"]) + # A reorg while capturing the new anchor can change an already + # scanned prefix. Validate the old anchor before discarding it. + if self._hex(conn.w3.eth.get_block(scan.head)["hash"]) != scan.head_hash: + self._approval_scans.pop(key, None) + raise ConfigurationError("CCTP head block changed during recovery; retry on a consistent provider") + scan.head, scan.head_hash = head, new_hash + else: + scan = _ApprovalScan(head, self._hex(conn.w3.eth.get_block(head)["hash"]), start, set()) + self._approval_scans[key] = scan + head, head_hash, start = scan.head, scan.head_hash, scan.next_block + sender = plan.sender or str(receipt.protocol_state["sourceSender"]) + last_nonce = max(int(conn.w3.eth.get_transaction(h)["nonce"]) for h in approvals) + found = scan.found + batch_end = min(head, start + 9999) + while start <= batch_end: + end = min(batch_end, start+999) + logs = conn.w3.eth.get_logs({"address": Web3.to_checksum_address(m.transmitter), + "topics": ["0x"+keccak(text="MessageSent(bytes)").hex()], "fromBlock": start, "toBlock": end}) + for event, log in self._events(conn,m.transmitter,TRANSMITTER_ABI,"MessageSent",logs): + try: + self._check_message(bytes(event["message"]),m,plan,sender) + except AttestationError: + continue + candidate = self._hash(self._hex(log["transactionHash"])) + transaction = conn.w3.eth.get_transaction(candidate) + if (self._hex(transaction["hash"]).lower() != candidate.lower() + or transaction["from"].lower() != sender.lower() + or int(transaction["nonce"]) <= last_nonce): + continue + found.add(candidate) + start = end+1 + scan.next_block = start + if self._hex(conn.w3.eth.get_block(head)["hash"]) != head_hash: + self._approval_scans.pop(key, None) + raise ConfigurationError("CCTP head block changed during recovery; retry on a consistent provider") + if start <= head: + return receipt.replace(status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={**receipt.protocol_state, "sourceError": "Reconciling source history; continue polling before resuming"}) + if len(found) > 1: + raise ConfigurationError("Multiple CCTP burns match this approval; recover the intended source transaction explicitly") + confirmed_nonce = conn.w3.eth.get_transaction_count(sender, head) + pending_nonce = conn.w3.eth.get_transaction_count(sender, "pending") + if self._hex(conn.w3.eth.get_block(head)["hash"]) != head_hash: + self._approval_scans.pop(key, None) + raise ConfigurationError("CCTP head block changed during recovery; retry on a consistent provider") + if found: + tx_hash = next(iter(found)) + return self.get_status(plan,receipt.replace(id=tx_hash,source_tx_id=tx_hash,status=Status.SOURCE_CONFIRMING)) + # The scan covers confirmed activity through this exact head, including + # unrelated transfers after the approval. Only later activity is unresolved. + if confirmed_nonce < last_nonce+1: + raise ConfigurationError("CCTP RPC nonce precedes the confirmed approval; retry on a consistent provider") + if pending_nonce > confirmed_nonce: + return receipt.replace(status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={**receipt.protocol_state,"sourceError":"Later source activity is unresolved; recover its burn hash before resuming"}) + return receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + + def recover(self, plan: Plan, checkpoint: Checkpoint, *, approval_replacement: dict[str, str] | None = None) -> Receipt: + from web3.exceptions import TransactionNotFound + m = self._metadata(plan) + receipt = self.from_checkpoint(plan, checkpoint) + if approval_replacement is not None: + if receipt.source_tx_id: + raise ConfigurationError("Cannot replace approvals after a CCTP burn was submitted") + if not isinstance(cast(object, approval_replacement), dict) or set(approval_replacement) != {"original_transaction_id", "replacement_transaction_id"}: + raise ConfigurationError("Invalid approval replacement selection") + original = self._hash(approval_replacement["original_transaction_id"]) + replacement = self._hash(approval_replacement["replacement_transaction_id"]) + active = list(receipt.protocol_state["approvalTxIds"]) + normalized = [v.lower() for v in active] + if original.lower() not in normalized or replacement.lower() in normalized: + raise ConfigurationError("Replacement must identify a saved approval and a different transaction") + conn = self._connection(m.source_chain, m.source_chain_id) + try: + original_tx = conn.w3.eth.get_transaction(original) + except TransactionNotFound: + original_tx = None + if original_tx is not None or conn.get_receipt(original) is not None: + raise ConfigurationError("Original approval is still visible; reconcile it first") + test = receipt.replace(protocol_state={**receipt.protocol_state, "approvalTxIds": [replacement]}) + if not self._approvals_confirmed(conn, m, plan, test): + raise ConfigurationError("Replacement approval must be confirmed before recovery") + active[normalized.index(original.lower())] = replacement + receipt = receipt.replace(id=active[-1], protocol_state={**receipt.protocol_state, "approvalTxIds": active, + "replacedApprovalTxIds": [*receipt.protocol_state.get("replacedApprovalTxIds", []), original]}) + return self.get_status(plan, receipt) + + @classmethod + def from_checkpoint(cls, plan: Plan, checkpoint: Checkpoint) -> Receipt: + source, destination = checkpoint.source or {}, checkpoint.destination or {} + if source.get("preparedTransaction") is not None or destination.get("preparedTransaction") is not None: + raise ConfigurationError("CCTP checkpoints cannot contain prepared Aleo transactions") + active = source.get("approvalTransactionIds", []) + replaced = source.get("replacedApprovalTransactionIds", []) + for values in (active, replaced): + if not isinstance(values, list): + raise ConfigurationError("Invalid CCTP approval checkpoint") + for value in cast(list[Any], values): + cls._hash(value) + tx_hash, dest_hash = source.get("transactionId"), destination.get("transactionId") + if tx_hash is not None: + cls._hash(tx_hash) + if dest_hash is not None: + cls._hash(dest_hash) + if not tx_hash and (not active or dest_hash): + raise ConfigurationError("CCTP checkpoint contains no submitted source transaction") + options = normalize_cctp(plan.cctp) + if options.max_fee is None: + raise ConfigurationError("CCTP checkpoint must preserve its approved fee cap") + if plan.sender is None: + raise ConfigurationError("CCTP checkpoint requires the source sender") + address_bytes(plan.sender) + return Receipt(tx_hash or active[-1], "cctp", Status.SOURCE_CONFIRMING if tx_hash else Status.SOURCE_APPROVAL_PENDING, + source_tx_id=tx_hash, destination_tx_id=dest_hash, + protocol_state={"routeId": plan.route_id, "sourceSender": plan.sender, + "approvalTxIds": list(active), "replacedApprovalTxIds": list(replaced)}) + + def complete(self, plan: Plan, receipt: Receipt, *, manual_mint: bool = False, + on_checkpoint: Callable[[Receipt], Any]) -> Receipt: + if type(manual_mint) is not bool: + raise ConfigurationError("manual_mint must be boolean") + m = self._metadata(plan) + state = self.get_status(plan, receipt) + if state.status in (Status.COMPLETED, Status.DESTINATION_CONFIRMING): + return state + fallback = manual_mint and state.status == Status.DELIVERY_PENDING and state.protocol_state.get("nonceUsed") is False + if state.status != Status.DESTINATION_ACTION_REQUIRED and not fallback: + raise ConfigurationError("CCTP transfer is not ready for manual destination minting") + conn = self._connection(m.destination_chain, m.destination_chain_id) + sender = conn.require_address() + if conn.w3.eth.get_balance(sender) <= 0: + raise BridgeError("Destination wallet requires native gas funds for CCTP mint") + transmitter = self._contract(conn, m.transmitter, TRANSMITTER_ABI) + args = [bytes.fromhex(state.protocol_state[k][2:]) for k in ("message", "attestation")] + return self._broadcast(conn, {"to": transmitter.address, "data": transmitter.encode_abi("receiveMessage", args=args)}, + state, on_checkpoint, plan=plan, destination=True) diff --git a/bridge-sdk/python/aleo_bridge/checkpoint.py b/bridge-sdk/python/aleo_bridge/checkpoint.py index d302231c..4cc603fe 100644 --- a/bridge-sdk/python/aleo_bridge/checkpoint.py +++ b/bridge-sdk/python/aleo_bridge/checkpoint.py @@ -157,6 +157,11 @@ def create_checkpoint(plan: Plan, receipt: Receipt, registry: Registry) -> Check source = {} if approvals: source["approvalTransactionIds"] = approvals + replaced = state.get("replacedApprovalTxIds") + if replaced is not None: + if not isinstance(replaced, list) or any(not isinstance(v, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", v) for v in replaced): + raise CheckpointInvalidError("Invalid replaced approval transaction identifiers") + source["replacedApprovalTransactionIds"] = list(replaced) if receipt.source_tx_id: source["transactionId"] = receipt.source_tx_id if isinstance(state.get("hookData"), str): @@ -197,6 +202,9 @@ def create_checkpoint(plan: Plan, receipt: Receipt, registry: Registry) -> Check "amount": plan.amount, "recipient": plan.recipient, } + if plan.cctp is not None: + from dataclasses import asdict + intent["cctp"] = asdict(plan.cctp) if sender: intent["sender"] = sender if dst_asset.locator is not None and dst_asset.locator.kind == "aleo-program": diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index fa5b6d36..b39545aa 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -15,7 +15,9 @@ from typing import TYPE_CHECKING, Any, Callable from . import lifecycle as _lifecycle +from . import _evm_connections from ._calls import AleoCall +from .cctp import CctpModule from .errors import BridgeError, ConfigurationError from .eth import Ethereum, EthModule from .freezelist import FreezeList @@ -122,7 +124,7 @@ 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: + registry: Registry | None = None, checkpoints: Any = None, evm: 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}") @@ -139,6 +141,9 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") self.checkpoints = checkpoints self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) + self._evm_connections = _evm_connections.normalize(self.registry, environment, evm, self.ethereum) + self._evm_modules: dict[str, EthModule] = {} + self.ethereum = self._evm_connections.get("ethereum" if environment == "mainnet" else "sepolia") self.solana: Solana | None = _coerce_solana(solana) solana = self.solana self._sol: SolModule | None = SolModule(self, solana) if solana is not None else None @@ -147,6 +152,7 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir self._programs: dict[str, Any] = {} self.hyperlane = HyperlaneModule(self) self.xreserve = XReserveModule(self) + self.cctp = CctpModule(self) self.freezelist = FreezeList(self) self.privacy = PrivacyModule(self) @@ -173,6 +179,22 @@ def sol(self) -> SolModule: "or set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) for Bridge.from_env()") return self._sol + def evm(self, chain_id: str) -> EthModule: + """Use the configured EVM provider for a registry chain, such as ``arc`` or ``base``. + + Reads need an RPC connection; sending also needs that connection's signer. + ``bridge.eth`` remains the Ethereum/Sepolia shortcut. + """ + chain = self.registry.chain(chain_id) + if chain.id == ("ethereum" if self.environment == "mainnet" else "sepolia"): + return self.eth + conn = self._evm_connections.get(chain.id) + if conn is None: + raise ConfigurationError(f"Configure evm={{'{chain.id}': Ethereum(...)}} or {chain.id.upper()}_RPC_URL") + if chain.id not in self._evm_modules: + self._evm_modules[chain.id] = EthModule(self, conn, chain_id=chain.id) + return self._evm_modules[chain.id] + # ── identity / registry helpers ── def aleo_chain(self) -> Chain: chains = [c for c in self.registry.chains(environment=self.environment) if c.family == "aleo"] @@ -284,6 +306,9 @@ def status(self) -> BridgeStatus: chains = [self._aleo_chain_status()] if self.ethereum is not None: chains.append(self.eth.chain_status()) + for chain_id in self._evm_connections: + if chain_id not in ("ethereum", "sepolia"): + chains.append(self.evm(chain_id).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 @@ -315,7 +340,7 @@ def routes(self, *, source_chain: str | None = None, source_asset: str | None = def quote(self, *, source_chain: str | None = None, source_asset: str | None = None, destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route=None, amount=None, amount_atomic=None, recipient: str, - sender: str | None = None, mint_mode: str = "public", secret_nonce: str = "0scalar"): + sender: str | None = None, mint_mode: str = "public", secret_nonce: str = "0scalar", cctp=None): """Price a transfer and get the plan that ``execute`` takes. Nothing is signed. Name the route the way veil's ``quote`` does: ``source_chain`` + ``source_asset`` @@ -336,7 +361,7 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N destination_chain=destination_chain, destination_asset=destination_asset, bridge_protocol=bridge_protocol, route=route, amount=amount, amount_atomic=amount_atomic, recipient=recipient, sender=sender, - mint_mode=mint_mode, secret_nonce=secret_nonce) + mint_mode=mint_mode, secret_nonce=secret_nonce, **({"cctp": cctp} if cctp is not None else {})) def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: str | None = None, record: str | None = None, merkle_proof: str | None = None, @@ -383,13 +408,17 @@ def wait(self, progress, *, until=None, poll_seconds: float = 15.0, timeout_seco timeout_seconds=timeout_seconds, on_update=on_update, on_error=on_error, max_consecutive_errors=max_consecutive_errors) - def recover(self, checkpoint): + def recover(self, checkpoint, *, approval_replacement=None): """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``. + CCTP can adopt an explicitly selected, confirmed ``approval_replacement``; + its original transaction must be absent and no burn may be submitted. """ + if approval_replacement is not None: + return _lifecycle.recover(self, checkpoint, approval_replacement=approval_replacement) return _lifecycle.recover(self, checkpoint) def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = None, @@ -403,15 +432,20 @@ def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = Non 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"``). + def complete(self, progress, *, secret_nonce: str | None = None, on_checkpoint=None, proving: str = "delegate", + manual_mint: bool = False): + """Claim a private USDCx or native-USDC CCTP destination mint. 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. + ``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret + nonce, but requires a destination signer and gas. Set ``manual_mint=True`` + to explicitly authorize fallback for stalled forwarding; an already + submitted destination transaction is observed rather than repeated. """ + options = {"manual_mint": manual_mint} if manual_mint else {} return _lifecycle.complete(self, progress, secret_nonce=secret_nonce, on_checkpoint=on_checkpoint, - proving=proving) + proving=proving, **options) def pending(self) -> list: """The in-flight transfers of this profile — every checkpoint in the bound store, @@ -431,7 +465,7 @@ def pending(self) -> list: return [] loader = getattr(store, "load_checkpoints", None) if callable(loader): - result = loader() + result: Any = loader() checkpoints, problems = result.checkpoints, result.errors else: checkpoints, problems = store.list(), [] @@ -451,7 +485,7 @@ def pending(self) -> list: @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"} + unexpected = set(overrides) - {"ethereum", "solana", "registry", "checkpoints", "evm"} if unexpected: raise TypeError(f"Bridge.from_env() got unexpected overrides: {sorted(unexpected)}") private_key = os.environ.get("BRIDGE_PRIVATE_KEY") @@ -459,21 +493,25 @@ def from_env(cls, **overrides: Any) -> "Bridge": 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() + evm = _evm_connections.from_env(overrides.get("evm"), environment=aleo.network_name) + ethereum = (overrides["ethereum"] if "ethereum" in overrides else + ethereum_from_env() if _evm_connections.needs_legacy_environment(evm) else None) 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) + return cls(aleo, ethereum=ethereum, solana=solana, registry=overrides.get("registry"), checkpoints=checkpoints, evm=evm) @classmethod def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: str | None = None, - ethereum: Any = None, solana: Any = None) -> "Bridge": + ethereum: Any = None, solana: Any = None, evm: 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(), + evm = _evm_connections.from_env(evm, environment=aleo.network_name) + bridge = cls(aleo, ethereum=ethereum if ethereum is not None else + ethereum_from_env() if _evm_connections.needs_legacy_environment(evm) else None, evm=evm, solana=solana if solana is not None else solana_from_env(), checkpoints=_checkpoints_for_profile(profile)) bridge.profile = profile diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index c8dd8333..0194de84 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -6,11 +6,13 @@ """ from __future__ import annotations +from ._registry_compatibility import is_registry_version_compatible + import os import re import time from dataclasses import dataclass, fields -from typing import Any, Mapping +from typing import Any, Mapping, cast from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep @@ -230,7 +232,7 @@ def require_address(self) -> str: "or set w3.eth.default_account with signing middleware") return address - def send_transaction(self, tx: dict) -> str: + def send_transaction(self, tx: dict[str, Any]) -> str: """Broadcast one transaction and return its ``0x`` hash. Fills ``from``/``chainId``/``value`` when missing. Local signer: also fills @@ -322,7 +324,7 @@ def wait_for_receipt(self, tx_hash: str, *, timeout_seconds: float, poll_seconds except TimeExhausted: return None - def get_receipt(self, tx_hash: str) -> dict | None: + def get_receipt(self, tx_hash: str) -> dict[str, Any] | None: """One ``eth_getTransactionReceipt`` read; ``None`` while the transaction is unmined or unknown.""" from web3.exceptions import TransactionNotFound @@ -443,12 +445,13 @@ class _XReserveQuote: 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: + def __init__(self, bridge: Any, conn: Ethereum, *, log_scan_chunk_blocks: int = LOG_SCAN_CHUNK_BLOCKS, + chain_id: str | None = None) -> 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.chain: Chain = self.registry.chain(chain_id or EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) self.log_scan_chunk_blocks = log_scan_chunk_blocks # recovery eth_getLogs span; lower it for strict RPCs self.log_scan_head_race_retries = LOG_SCAN_HEAD_RACE_RETRIES self.log_scan_head_race_sleep = LOG_SCAN_HEAD_RACE_SLEEP_SECONDS @@ -514,7 +517,7 @@ def _xreserve_route(self) -> Route: 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: + if not is_registry_version_compatible(self.registry, plan.registry_version, plan.route_id): raise RegistryVersionMismatchError( f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") route = self.registry.route(plan.route_id) @@ -526,7 +529,7 @@ def _route_for_plan(self, plan: Plan) -> 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: + if not is_registry_version_compatible(self.registry, plan.registry_version, plan.route_id): raise RegistryVersionMismatchError( f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") try: @@ -558,6 +561,9 @@ def _assert_plan_matches(self, plan: Plan, route: Route, *, sender: str, recipie for field in fields(Plan): if field.name == "journal_id": # Local storage identity does not change the transfer. continue + if field.name == "registry_version" and is_registry_version_compatible( + self.registry, plan.registry_version, plan.route_id): + continue 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} " @@ -691,7 +697,7 @@ def _quote_hyperlane(self, route: Route, recipient_bytes32: bytes, amount_atomic 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 + allowance = int(self._erc20(cast(str, 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) @@ -726,6 +732,7 @@ def quote_transfer_remote(self, asset: Any = None, recipient: str | None = None, 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) + recipient = cast(str, recipient) # required above when no plan supplies it recipient32 = self._recipient_bytes32(route, recipient) owner = self._owner(sender) q = self._quote_hyperlane(route, recipient32, atomic, owner) @@ -882,6 +889,7 @@ def quote_deposit_usdc(self, recipient: str | None = None, *, amount: Any = None mint_mode = "public" if mint_mode is None else mint_mode atomic = self._amount_atomic(route, amount, amount_atomic) owner = self._owner(sender) + recipient = cast(str, recipient) # required above or supplied by the validated plan 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) @@ -937,7 +945,7 @@ def _hyperlane_result(self, route: Route, q: "_HyperlaneQuote", outcome: EvmOutc native_value_atomic=q.native_value_atomic, amount_atomic=q.amount_atomic, approval_tx_ids=approvals, sender=outcome.sender, message_id=message_id, source_nonce=outcome.source_nonce) - receipt = Receipt(id=rid, protocol="hyperlane", status=status, source_tx_id=outcome.source_tx_id, protocol_state=state) + receipt = Receipt(id=cast(str, 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) @@ -969,6 +977,7 @@ def transfer_remote(self, asset: Any = None, recipient: str | None = None, *, am route = self._hyperlane_route(self._asset(asset)) sender = self.conn.require_address() atomic = self._amount_atomic(route, amount, amount_atomic) + recipient = cast(str, recipient) # required above when plan is absent plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender) recipient32 = self._recipient_bytes32(route, recipient) latest: dict[str, _HyperlaneQuote] = {} @@ -978,10 +987,11 @@ def steps(owner: str) -> list[EvmStep]: 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) + token_address = cast(str, q.token) # collateral quotes always have a token + token = self._erc20(token_address) 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]))) + out.append(EvmStep("approve", token_address, token.encode_abi("approve", args=[q.router, 0]))) + out.append(EvmStep("approve", token_address, 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]), @@ -1068,9 +1078,9 @@ def _xreserve_result(self, route: Route, q: _XReserveQuote, outcome: EvmOutcome, 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, + source_tx_id=cast(str, 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, + return DepositReceipt(transaction_id=cast(str, 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, @@ -1108,6 +1118,7 @@ def deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amou mint_mode = "public" if mint_mode is None else mint_mode sender = self.conn.require_address() atomic = self._amount_atomic(route, amount, amount_atomic) + recipient = cast(str, recipient) # required above when plan is absent plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender, mint_mode=mint_mode) latest: dict[str, _XReserveQuote] = {} @@ -1712,7 +1723,7 @@ def recover_source(self, plan: Plan, checkpoint: Checkpoint, *, required: bool = """ 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: + if not is_registry_version_compatible(self.registry, checkpoint.route.get("registryVersion"), checkpoint.route.get("id")): raise RegistryVersionMismatchError( f"Checkpoint uses registry {checkpoint.route.get('registryVersion')}; this client has {self.registry.version}") if plan.protocol == "hyperlane" and checkpoint.destination: diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index a6a9b76d..2d943f41 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -13,11 +13,13 @@ """ from __future__ import annotations +from ._registry_compatibility import is_registry_version_compatible + import json import re import time from dataclasses import dataclass, replace -from typing import Any, Callable +from typing import Any, Callable, cast from . import _sealevel from ._calls import is_duplicate_submission @@ -67,7 +69,7 @@ def resolve_route(registry: Registry, plan: Plan) -> ResolvedRoute: different registry version (re-quote to fix) and :class:`CheckpointInvalidError` when its route topology no longer matches. """ - if plan.registry_version != registry.version: + if not is_registry_version_compatible(registry, plan.registry_version, plan.route_id): raise RegistryVersionMismatchError( f"Plan uses registry {plan.registry_version}; this client has " f"{registry.version}. Re-run quote() to rebuild the plan.") @@ -124,7 +126,7 @@ def prepare(registry: Registry, *, source_chain: str | None = None, source_asset destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route: "Route | str | None" = None, amount=None, amount_atomic=None, recipient: str, sender: str | None = None, - mint_mode: str = "public") -> Plan: + mint_mode: str = "public", cctp=None) -> Plan: """Describe how *amount* moves along one route — pure, no network. The route is named the way veil's ``quote`` names it: ``source_chain`` / @@ -168,12 +170,12 @@ def prepare(registry: Registry, *, source_chain: str | None = None, source_asset f"({dst.address_regex})") return build_plan(registry, route, amount_atomic=atomic, recipient=recipient, - sender=sender, mint_mode=mint_mode) + sender=sender, mint_mode=mint_mode, cctp=cctp) # ── Connection helpers ──────────────────────────────────────────────────────── -def _module(bridge, name: str): +def _module(bridge: Any, name: str, chain_id: str | None = None) -> Any: """``bridge.eth`` / ``bridge.sol`` or a ConfigurationError that says how to fix it. Checks the ``ethereum``/``solana`` connection attribute FIRST: on the real @@ -182,6 +184,11 @@ def _module(bridge, name: str): would never see the ``None`` default — it would let that raise propagate with the property's own (less specific) message instead of this one. """ + if name == "eth" and chain_id not in (None, "ethereum", "sepolia"): + accessor = getattr(bridge, "evm", None) + if callable(accessor): + return accessor(chain_id) + raise ConfigurationError(f"This transfer needs a configured {chain_id} EVM connection") 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" @@ -203,7 +210,7 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route: "Route | str | None" = None, amount=None, amount_atomic=None, recipient: str, sender: str | None = None, - mint_mode: str = "public", secret_nonce: str = "0scalar") -> Quote: + mint_mode: str = "public", secret_nonce: str = "0scalar", cctp=None) -> Quote: """Price a transfer: ``prepare`` + the source-side live read for the route kind. Returns one of ``EvmHyperlaneQuote`` / ``SolanaHyperlaneQuote`` / @@ -225,13 +232,15 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = plan = prepare(bridge.registry, source_chain=source_chain, source_asset=source_asset, destination_chain=destination_chain, destination_asset=destination_asset, bridge_protocol=bridge_protocol, route=route, amount=amount, amount_atomic=amount_atomic, - recipient=recipient, sender=sender, mint_mode=mint_mode) + recipient=recipient, sender=sender, mint_mode=mint_mode, cctp=cctp) resolved = resolve_route(bridge.registry, plan) _require_active(resolved.route) family = resolved.source_chain.family + if plan.protocol == "cctp": + return bridge.cctp.quote(plan) if plan.protocol == "hyperlane" and family == "evm": - q = _module(bridge, "eth").quote_transfer_remote(plan=plan) + q = _module(bridge, "eth", resolved.source_chain.id).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) @@ -246,10 +255,13 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = 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) + q = _module(bridge, "eth", resolved.source_chain.id).quote_deposit_usdc(plan=plan, secret_nonce=secret_nonce) return replace(q, plan=plan) if plan.protocol == "xreserve" and family == "aleo": fee_atomic = _xreserve_withdrawal_fee_atomic(resolved) + live_fee = resolved.route.metadata.get("withdrawalFeeUrl") is not None + if live_fee: + fee_atomic, _ = bridge.xreserve.read_withdrawal_fee(resolved.route, plan.amount_atomic) if fee_atomic is None: raise RouteUnavailableError(f"xReserve withdrawal fee is missing or invalid: {plan.route_id}") decimals = resolved.source_asset.decimals @@ -267,7 +279,7 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = fees=(Fee(kind="protocol", chain_id=resolved.source_chain.id, asset_id=resolved.source_asset.id, amount=fee_human, estimated=True),), amount_out=format_decimal_amount(plan.amount_atomic - fee_atomic, decimals), - withdrawal_fee_atomic=fee_atomic) + withdrawal_fee_atomic=fee_atomic, status="quoted" if live_fee else "not-queried") raise UnsupportedRouteError( f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") @@ -415,11 +427,15 @@ def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> in """ chain, asset = resolved.destination_chain, resolved.destination_asset if chain.family == "evm": - conn = getattr(bridge, "ethereum", None) + try: + module = _module(bridge, "eth", chain.id) + except ConfigurationError: + return None + conn = getattr(module, "conn", None) or getattr(bridge, "ethereum", None) if (conn is None or asset.locator is None or asset.locator.kind not in ("native", "evm-contract")): return None - return int(bridge.eth.balance(asset.id) if conn.address and conn.address.lower() == plan.recipient.lower() - else bridge.eth.balance(asset.id, address=plan.recipient)) + return int(module.balance(asset.id) if conn.address and conn.address.lower() == plan.recipient.lower() + else module.balance(asset.id, address=plan.recipient)) if chain.family == "solana": conn = getattr(bridge, "solana", None) if (conn is None or asset.locator is None or asset.locator.kind != "native"): @@ -559,14 +575,22 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin _require_active(resolved.route) family = resolved.source_chain.family store = getattr(bridge, "checkpoints", None) + if plan.protocol == "cctp": + plan = bridge.cctp.quote(plan).plan reserve = getattr(store, "reserve", None) if callable(reserve): plan = replace(plan, journal_id=reserve(plan)) emit = _Emitter(bridge, plan, on_checkpoint) + if plan.protocol == "cctp": + receipt = bridge.cctp.execute(plan, on_checkpoint=emit, poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) + if plan.protocol == "hyperlane" and family == "evm": - eth = _module(bridge, "eth") - _assert_sender(plan, bridge.ethereum.address, family=family) + eth = _module(bridge, "eth", resolved.source_chain.id) + _assert_sender(plan, (getattr(eth, "conn", None) or bridge.ethereum).address, family=family) call = eth.transfer_remote(plan=plan) receipt = _send_call(call, emit, poll_seconds, timeout_seconds) emit.finalize() @@ -595,8 +619,8 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin return to_progress(plan, receipt) if plan.protocol == "xreserve" and family == "evm": - eth = _module(bridge, "eth") - _assert_sender(plan, bridge.ethereum.address, family=family) + eth = _module(bridge, "eth", resolved.source_chain.id) + _assert_sender(plan, (getattr(eth, "conn", None) or 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) @@ -613,11 +637,18 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin # the full amount would wait for a delivery that can never arrive. With no readable fee # there is no honest expectation to record, and the branch degrades to veil's passthrough. fee_atomic = _xreserve_withdrawal_fee_atomic(resolved) + if resolved.route.metadata.get("withdrawalFeeUrl") is not None: + fee_atomic, _ = bridge.xreserve.read_withdrawal_fee(resolved.route, plan.amount_atomic) verification = ({} if fee_atomic is None else _delivery_verification(bridge, plan, resolved, expected_atomic=plan.amount_atomic - fee_atomic)) - call = bridge.xreserve.burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, - record=record, merkle_proof=merkle_proof) + burn = bridge.xreserve.burn + fee_options = {} + if resolved.route.metadata.get("withdrawalFeeUrl") is not None: + burn = bridge.xreserve._burn + fee_options["withdrawal_fee_atomic"] = fee_atomic + call = burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, + record=record, merkle_proof=merkle_proof, route=resolved.route, **fee_options) receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state=verification) emit.finalize() return to_progress(plan, receipt) @@ -728,11 +759,13 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: if receipt.status in TERMINAL: return receipt route, src, dst = resolved.route, resolved.source_chain, resolved.destination_chain + if plan.protocol == "cctp": + return bridge.cctp.get_status(plan, receipt) 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) + return _module(bridge, "eth", resolved.source_chain.id).source_status(plan, receipt) # 2. Aleo source acceptance is the irreversible boundary if receipt.status is Status.SOURCE_CONFIRMING and src.family == "aleo": @@ -749,7 +782,7 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> 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) + return _module(bridge, "eth", resolved.source_chain.id).source_status(plan, receipt) if src.family == "solana": return _module(bridge, "sol").source_status(plan, receipt) @@ -773,7 +806,7 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: 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)) + else _module(bridge, "eth", resolved.destination_chain.id).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 @@ -826,7 +859,7 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: return _clear_action(receipt, status=Status.COMPLETED) if receipt.status is Status.SOURCE_CONFIRMING: - return _module(bridge, "eth").source_status(plan, receipt) + return _module(bridge, "eth", resolved.source_chain.id).source_status(plan, receipt) if receipt.status is Status.ATTESTATION_PENDING: message_hash = state.get("messageHash") @@ -891,6 +924,10 @@ def _is_transient_error(exc: Exception) -> bool: import requests if isinstance(exc, requests.RequestException): return True + # CCTP keeps a sanitized AttestationError at the API boundary. Its + # transport cause is retryable; malformed or mismatched evidence is not. + if isinstance(exc, AttestationError) and isinstance(exc.__cause__, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError)): + return True except ImportError: pass try: @@ -1020,7 +1057,7 @@ def _plan_from_intent(registry: Registry, intent: dict[str, Any]) -> Plan: destination_asset=intent["destination"]["asset"], bridge_protocol=intent.get("bridgeProtocol"), amount=intent["amount"], recipient=intent["recipient"], sender=intent.get("sender"), - mint_mode=intent.get("mintMode", "public")) + mint_mode=intent.get("mintMode", "public"), cctp=intent.get("cctp")) except (KeyError, TypeError) as exc: raise CheckpointInvalidError(f"Bridge checkpoint intent is incomplete: missing {exc}") from exc @@ -1088,6 +1125,9 @@ def _reconstruct_source_receipt(plan: Plan, resolved: ResolvedRoute, cp: Checkpo offline guess here is never unsafe); anything else has nothing to build a receipt from. """ src = resolved.source_chain + if plan.protocol == "cctp": + from .cctp import CctpModule + return CctpModule.from_checkpoint(plan, cp) source = cp.source or {} approvals = [a for a in (source.get("approvalTransactionIds") or []) if isinstance(a, str)] @@ -1164,6 +1204,8 @@ def _apply_destination_overlay(resolved: ResolvedRoute, cp: Checkpoint, receipt: destination = cp.destination or {} if not destination: return receipt + if resolved.route.protocol == "cctp" and not destination.get("preparedTransaction"): + return receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=destination.get("transactionId")) 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") @@ -1202,7 +1244,7 @@ def progress_from_checkpoint(registry: Registry, checkpoint) -> Progress: 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 = replace(_plan_from_intent(registry, cp.intent), journal_id=cp.journal_id) - if cp.route.get("registryVersion") != plan.registry_version: + if not is_registry_version_compatible(registry, cp.route.get("registryVersion"), plan.route_id): 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.") @@ -1220,7 +1262,7 @@ def progress_from_checkpoint(registry: Registry, checkpoint) -> Progress: return to_progress(plan, receipt) -def recover(bridge, checkpoint) -> Progress: +def recover(bridge, checkpoint, *, approval_replacement=None) -> 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 @@ -1237,7 +1279,7 @@ def recover(bridge, checkpoint) -> Progress: 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 = replace(_plan_from_intent(bridge.registry, cp.intent), journal_id=cp.journal_id) - if cp.route.get("registryVersion") != plan.registry_version: + if not is_registry_version_compatible(bridge.registry, cp.route.get("registryVersion"), plan.route_id): 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.") @@ -1248,6 +1290,13 @@ def recover(bridge, checkpoint) -> Progress: src, dst = resolved.source_chain, resolved.destination_chain verification = _verification_from_delivery(cp.delivery_verification or {}) + if plan.protocol == "cctp": + receipt = bridge.cctp.recover(plan, cp, approval_replacement=approval_replacement) + _persist(bridge, create_checkpoint(plan, receipt, bridge.registry), receipt, previous_id=cp.id) + return _finish(bridge, plan, receipt, cp.id) + if approval_replacement is not None: + raise ConfigurationError("approval_replacement is only supported for CCTP") + if src.family in ("aleo", "solana"): receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) if receipt.status is Status.SOURCE_SUBMISSION_PENDING: @@ -1259,13 +1308,13 @@ def recover(bridge, checkpoint) -> Progress: 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) + receipt = _module(bridge, "eth", resolved.source_chain.id).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) + receipt = _module(bridge, "eth", resolved.source_chain.id).recover_source(plan, cp, required=False) destination = cp.destination or {} prepared_dest = destination.get("preparedTransaction") if prepared_dest and destination.get("transactionId"): @@ -1369,6 +1418,14 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, state = receipt.protocol_state family = resolved.source_chain.family + if plan.protocol == "cctp": + emit.supersede(receipt.id) + receipt = bridge.cctp.execute(plan, resume=receipt, on_checkpoint=emit, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) + if family == "aleo": serialized = state.get("preparedTransaction") if not isinstance(serialized, str) or not serialized: @@ -1397,8 +1454,8 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, 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") + eth = _module(bridge, "eth", resolved.source_chain.id) + _assert_sender(plan, (getattr(eth, "conn", None) or 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") @@ -1421,7 +1478,7 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, 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 + if cast(str, saved_hook).lower() != ("0x" + quoted.hook_data.hex()).lower(): # 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 " @@ -1451,8 +1508,12 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, # ── 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. + on_checkpoint: Callable | None = None, proving: str = "delegate", manual_mint: bool = False) -> Progress: + """Claim a private USDCx or CCTP destination mint after refreshing delivery evidence. + + CCTP completion requires a destination signer and gas, but no secret nonce. + ``manual_mint=True`` authorizes fallback when forwarding has stalled. Known + destination transactions are observed instead of submitted again. Requires ``progress.next == "complete"`` — Circle has attested the deposit and the receipt carries ``next_action == {"kind": "xreserve-private-mint", "chainId": }``. The @@ -1471,6 +1532,15 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, value used at ``execute``; it is required for a private plan on the proving path (``ConfigurationError``, raised before any RPC). """ + if progress.plan.protocol == "cctp": + plan = progress.plan + emit = _Emitter(bridge, plan, on_checkpoint) + receipt = bridge.cctp.complete(plan, progress.receipt, manual_mint=manual_mint, on_checkpoint=emit) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) + if manual_mint: + raise ConfigurationError("manual_mint is only supported for CCTP") if progress.next != "complete": raise NotResumableError( "Bridge progress has no destination action to complete (next must be 'complete'); call " diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index 78be2012..de6d30b8 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -73,7 +73,7 @@ def select_record(self, program: str, amount_atomic: int, account: Any = None) - 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: + if value is not None and isinstance(plaintext, str): amounts.append((value, plaintext)) covering = [entry for entry in amounts if entry[0] >= amount_atomic] if not covering: diff --git a/bridge-sdk/python/aleo_bridge/registry.py b/bridge-sdk/python/aleo_bridge/registry.py index f383479e..2c77686e 100644 --- a/bridge-sdk/python/aleo_bridge/registry.py +++ b/bridge-sdk/python/aleo_bridge/registry.py @@ -275,6 +275,12 @@ def validate_registry(registry: Registry) -> Registry: 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 == "cctp": + for chain, key in ((source_chain, "sourceDomain"), (destination_chain, "destinationDomain")): + domain = chain.protocol_domains.get("cctp") + if (chain.family != "evm" or type(domain) is not int or not 0 <= domain <= 0xFFFFFFFF + or type(route.metadata.get(key)) is not int or route.metadata.get(key) != domain): + raise ConfigurationError(f"CCTP route domains must match configured chain domains: {route.id}") if route.protocol == "hyperlane" and route.availability == "active" and source_chain.family == "solana": for key in _SOLANA_REQUIRED_METADATA: value = route.metadata.get(key) diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index d7c3f10c..cfc3ad68 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -7,6 +7,8 @@ """ from __future__ import annotations +from ._registry_compatibility import is_registry_version_compatible + import asyncio import base64 import inspect @@ -15,7 +17,7 @@ import threading import time from dataclasses import dataclass, replace -from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable +from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable, TYPE_CHECKING import requests @@ -39,6 +41,9 @@ from .types import DispatchReceipt, Fee, Plan, Receipt, SolanaHyperlaneQuote, Status, Step from .units import format_decimal_amount, resolve_amount +if TYPE_CHECKING: + from .checkpoint import Checkpoint, CheckpointStore + DEFAULT_SOLANA_RPC_URL = "https://api.mainnet-beta.solana.com" CONFIRMED = "confirmed" COMMITMENTS = ("processed", "confirmed", "finalized") @@ -351,11 +356,12 @@ def is_blockhash_valid(self, blockhash: Any, commitment: str | None = None) -> A return self._run(self._client._provider.make_request(request, IsBlockhashValidResp)) def send_raw_transaction(self, txn: bytes, opts: Any = None) -> Any: + from importlib import import_module try: - from solana.rpc.models import TxOpts + TxOpts = import_module("solana.rpc.models").TxOpts except ImportError: # solana-py < 0.36 kept TxOpts in solana.rpc.types try: - from solana.rpc.types import TxOpts + TxOpts = import_module("solana.rpc.types").TxOpts except ImportError as exc: raise MissingExtraError("solana", "solana-py AsyncClient transport") from exc opts = opts or SendOptions() @@ -705,7 +711,7 @@ def quote_transfer_remote(self, recipient: str | None = None, *, amount: str | N 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: + if not is_registry_version_compatible(self.registry, plan.registry_version, plan.route_id): 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: diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py index 6c9ea3b5..399f6559 100644 --- a/bridge-sdk/python/aleo_bridge/types.py +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any +from collections.abc import Mapping from .errors import ConfigurationError @@ -57,6 +58,46 @@ class Fee: estimated: bool +@dataclass(frozen=True) +class CctpOptions: + """Choose CCTP delivery speed, forwarding, and the maximum deduction in USDC. + + Forwarding delivers without a destination signer and can spend the full fee + ceiling. Manual delivery requires destination gas and a call to ``complete``. + An omitted ceiling is fixed by the quote, never raised during execution. + """ + speed: str = "standard" + forwarding: bool = True + max_fee: str | None = None + + def __post_init__(self) -> None: + from .units import parse_decimal_amount, format_decimal_amount + if self.speed not in ("standard", "fast"): + raise ConfigurationError("CCTP speed must be standard or fast") + if type(self.forwarding) is not bool: + raise ConfigurationError("CCTP forwarding must be a boolean") + if self.max_fee is not None: + if not isinstance(self.max_fee, str): + raise ConfigurationError("CCTP max_fee must be a decimal USDC string") + atomic = parse_decimal_amount(self.max_fee, 6) + if atomic >= 2**256: + raise ConfigurationError("CCTP max_fee must fit uint256") + object.__setattr__(self, "max_fee", format_decimal_amount(atomic, 6)) + + +def normalize_cctp(value: Any) -> CctpOptions: + if value is None: + return CctpOptions() + if isinstance(value, CctpOptions): + return value + if isinstance(value, Mapping): + try: + return CctpOptions(**value) + except TypeError as exc: + raise ConfigurationError("Invalid CCTP options; use speed, forwarding, max_fee") from exc + raise ConfigurationError("CCTP options must be CctpOptions or a mapping") + + @dataclass(frozen=True) class Plan: route_id: str @@ -73,6 +114,7 @@ class Plan: steps: tuple[Step, ...] #: Local recovery identity, allocated by execute; does not change the quoted transfer. journal_id: str | None = field(default=None, compare=False) + cctp: CctpOptions | None = None def to_dict(self) -> dict[str, Any]: d = dataclasses.asdict(self) @@ -83,6 +125,8 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, d: dict[str, Any]) -> "Plan": data = dict(d) data["steps"] = tuple(Step(**s) for s in data.get("steps", ())) + if data.get("cctp") is not None: + data["cctp"] = normalize_cctp(data["cctp"]) return cls(**data) @@ -94,6 +138,18 @@ class Quote: amount_out: str | None +@dataclass(frozen=True) +class EvmCctpQuote(Quote): + """Report the CCTP deduction and receive amount in exact USDC base units.""" + amount_atomic: int + amount_out_atomic: int + protocol_fee_atomic: int + forwarding_fee_atomic: int + max_fee_atomic: int + min_finality_threshold: int + forwarding: bool + + @dataclass(frozen=True) class EvmHyperlaneQuote(Quote): recipient_bytes32: bytes @@ -133,6 +189,7 @@ class EvmXReserveQuote(Quote): @dataclass(frozen=True) class AleoXReserveQuote(Quote): withdrawal_fee_atomic: int + status: str = "not-queried" @dataclass @@ -273,5 +330,5 @@ class BridgeStatus: "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", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "CctpOptions", "EvmCctpQuote", ] diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index 2834c577..f3f4e15b 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any +import requests + from . import encoding as enc from ._calls import AleoCall from ._keccak import keccak256 @@ -33,7 +35,9 @@ def __init__(self, bridge: "Bridge") -> None: def _single(self, direction: str) -> Route: registry, aleo = self._bridge.registry, self._bridge.aleo_chain().id matches = [r for r in registry.routes(bridge_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 registry.asset(r.destination_asset_id if direction == "inbound" else r.source_asset_id).chain_id == aleo + and registry.asset(r.source_asset_id if direction == "inbound" else r.destination_asset_id).chain_id + == ("ethereum" if self._bridge.environment == "mainnet" else "sepolia")] if not matches: raise RouteNotFoundError(f"No {direction} xReserve route for {self._bridge.environment}") if len(matches) > 1: @@ -62,10 +66,43 @@ def _validated(self, route: Route, *, direction: str) -> Route: 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}") + evm_asset = registry.asset(route.destination_asset_id if direction == "burn" else route.source_asset_id) + expected = registry.chain(evm_asset.chain_id).protocol_domains.get("xreserve") + key = "arcDestinationDomain" if expected == 26 else "ethereumDestinationDomain" + if type(expected) is not int or expected not in (0, 26) or route.meta_int(key) != expected: + raise ConfigurationError(f"xReserve destination domain must match its EVM chain: {route.id}") return route + def read_withdrawal_fee(self, route: Route, amount_atomic: int) -> tuple[int, bool]: + """Estimate the USDCx withdrawal deduction and reject a burn that cannot cover it. + + A live estimate is rechecked before proving, but the Aleo burn has no on-chain fee cap. + """ + self.build_burn_inputs(route, mode="public", amount_atomic=amount_atomic, + recipient="0x" + "00" * 19 + "01", record=None, merkle_proof=None) + fee = int(route.meta_str("withdrawalFeeAtomic")) + url = route.metadata.get("withdrawalFeeUrl") + if url is None: + return fee, False + if not isinstance(url, str) or not url.startswith("https://"): + raise ConfigurationError("xReserve withdrawal fee URL must use HTTPS") + session = self.circle_session or requests.Session() + try: + response = session.post(url, json={"evmChain": route.meta_str("withdrawalFeeChain"), + "amountUsdc": format_decimal_amount(amount_atomic, 6)}, timeout=30) + if response.status_code != 200: + raise AttestationError(f"xReserve withdrawal fee request failed: HTTP {response.status_code}") + body = response.json() + except (requests.RequestException, ValueError) as exc: + raise AttestationError("xReserve withdrawal fee request failed") from exc + raw = body.get("withdrawalFeeBaseUnits") if isinstance(body, dict) else None + if not isinstance(raw, str) or not raw.isascii() or not raw.isdigit(): + raise AttestationError("xReserve withdrawal fee response is invalid") + fee = int(raw) + if fee >= amount_atomic: + raise InvalidAmountError("USDCx burn amount must exceed the live withdrawal fee") + return fee, True + # ── 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]]: @@ -77,11 +114,18 @@ def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, reci if amount_atomic <= 0: raise InvalidAmountError("USDCx burn amount must be greater than zero") fee = int(route.meta_str("withdrawalFeeAtomic")) + minimum = route.metadata.get("minimumBurnAmountAtomic") + if minimum is not None: + if not isinstance(minimum, str) or not minimum.isascii() or not minimum.isdigit(): + raise ConfigurationError("xReserve minimum burn amount is invalid") + if amount_atomic < int(minimum): + raise InvalidAmountError(f"USDCx burn amount is below the configured minimum: {minimum} base units") 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) + domain = self._bridge.registry.chain(self._bridge.registry.asset(route.destination_asset_id).chain_id).protocol_domains["xreserve"] + amount_lit, domain_lit, recipient_lit = f"{amount_atomic}u128", f"{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')}") @@ -92,18 +136,30 @@ def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, reci return route.meta_str("bridgeProgram"), function, [amount_lit, domain_lit, recipient_lit] def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", - record: str | None = None, merkle_proof: str | None = None) -> AleoCall[BurnReceipt]: - """Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a + record: str | None = None, merkle_proof: str | None = None, route: Route | None = None) -> AleoCall[BurnReceipt]: + """Burn USDCx for USDC on the selected EVM route (Ethereum by default). ``private`` spends a Token record via the wrapper and needs a freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle.""" + return self._burn(recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, + record=record, merkle_proof=merkle_proof, route=route) + + def _burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + mode: str = "private", record: str | None = None, merkle_proof: str | None = None, + route: Route | None = None, withdrawal_fee_atomic: int | None = None) -> AleoCall[BurnReceipt]: + """Build a burn with the lifecycle's validated execution-time estimate, if supplied.""" if mode not in BURN_MODES: raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") 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") + route = self._validated(self._bridge.registry.route(route.id) if route is not None else self.outbound_route(), direction="burn") source = self._bridge.registry.asset(route.source_asset_id) atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + if route.metadata.get("withdrawalFeeUrl") is not None and withdrawal_fee_atomic is None: + withdrawal_fee_atomic, _ = self.read_withdrawal_fee(route, atomic) + if withdrawal_fee_atomic is not None and (type(withdrawal_fee_atomic) is not int + or withdrawal_fee_atomic < 0 or withdrawal_fee_atomic >= atomic): + raise InvalidAmountError("USDCx burn amount must exceed the live withdrawal fee") if mode == "private": token_program = route.meta_str("remoteToken") if record is None: @@ -126,7 +182,7 @@ def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None 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, + "nativeDomain": self._bridge.registry.chain(self._bridge.registry.asset(route.destination_asset_id).chain_id).protocol_domains["xreserve"], "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) diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 400dd491..cc29f1d8 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -178,11 +178,11 @@ def _route(self): else "xreserve:aleo-testnet/usdcx->sepolia/usdc") def burn(self, recipient, *, amount=None, amount_atomic=None, mode="private", - record=None, merkle_proof=None) -> FakeAleoCall: + record=None, merkle_proof=None, route=None) -> FakeAleoCall: kw = dict(recipient=recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, record=record, merkle_proof=merkle_proof) self.fake.calls.append(("xreserve.burn", kw)) - route_id = self._route() + route_id = route.id if route is not None else self._route() program = "shielded_usdcx_wrapper.aleo" if mode == "private" else "usdcx_bridge_v2.aleo" fn = {"private": "private_burn", "public": "burn_public", "public-as-signer": "burn_public_as_signer"}[mode] diff --git a/bridge-sdk/tests/fakes/fake_cctp.py b/bridge-sdk/tests/fakes/fake_cctp.py new file mode 100644 index 00000000..3c610176 --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_cctp.py @@ -0,0 +1,124 @@ +"""CCTP network fixtures behind real Web3 ABI encoding, event decoding, and signing. + +Messages adapt the vendored Veil cctp.test.ts fixture to each route and local test key. +""" +import json +from pathlib import Path + +from eth_abi import encode +from eth_account import Account +from eth_utils import keccak +from web3 import Web3 + +from aleo_bridge import Ethereum +from aleo_bridge.registry import DEFAULT_REGISTRY +from tests.fakes.fake_web3 import FakeRpcProvider, make_bridge, event_log, ZERO_ADDRESS +from tests.test_cctp_quote import CircleSession + +KEY = '0x' + '11' * 32 +SENDER = Account.from_key(KEY).address +RECIPIENT = '0x0000000000000000000000000000000000000022' +DEST_HASH = '0x' + (13).to_bytes(32, 'big').hex() +NONCE = (123).to_bytes(32, 'big') +FIXTURE = json.loads((Path(__file__).parents[1] / 'fixtures/cctp-v2.json').read_text()) + + +class CctpProvider(FakeRpcProvider): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.used = False + + def _receipt(self, tx_hash): + receipt = super()._receipt(tx_hash) + if receipt is not None and tx_hash not in self.receipts: + # Reconciliation scans only mined blocks. Keep synthetic mined receipts + # at the reported head, instead of FakeRpcProvider's head + 1 default. + receipt['blockNumber'] = hex(self.block_number) + return receipt + + def make_request(self,method,params): + response = super().make_request(method,params) + if method == 'eth_getTransactionByHash' and response.get('result'): + sent = next((tx for tx in self.sent if tx['hash'] == params[0]),None) + if sent is not None: + response['result']['nonce'] = hex(sent['nonce']) + return response + + def _call(self, call): + raw = bytes.fromhex(call.get('data', call.get('input', '0x'))[2:]) + if raw[:4] == keccak(text='usedNonces(bytes32)')[:4]: + return '0x' + encode(['uint256'], [int(self.used)]).hex() + return super()._call(call) + + +class CctpCircle(CircleSession): + def __init__(self, harness): + super().__init__() + self.harness = harness + self.attestation_status = 'complete' + self.forward_hash = DEST_HASH + self.messages = None + + def json(self): + if '/fees/' in self.urls[-1]: + return super().json() + return {'messages': self.messages if self.messages is not None else [{ + 'message': '0x' + self.harness.attested.hex(), 'attestation': '0xabcd', + 'status': self.attestation_status, 'forwardTxHash': self.forward_hash}]} + + +class Harness: + def __init__(self, source='ethereum', destination='arc', *, forwarding=True, allowance=5_000_000, + checkpoints=None): + self.route = DEFAULT_REGISTRY.route(f'cctp:{source}/usdc->{destination}/usdc') + m = self.route.metadata + self.messenger, self.transmitter = m['tokenMessenger'], m['messageTransmitter'] + self.source_token = DEFAULT_REGISTRY.asset(f'{source}/usdc').locator.value + self.destination_token = DEFAULT_REGISTRY.asset(f'{destination}/usdc').locator.value + self.forwarding = forwarding + self.source = CctpProvider(chain_id=m['sourceChainId'], eth_balances={SENDER: 10**18}, + token_balances={(self.source_token, SENDER): 10_000_000}, + allowances={(self.source_token, SENDER, self.messenger): allowance}) + self.destination = CctpProvider(chain_id=m['destinationChainId'], eth_balances={SENDER: 10**18}) + self.bridge = make_bridge(evm={source: Ethereum(w3=Web3(self.source), private_key=KEY), + destination: Ethereum(w3=Web3(self.destination), private_key=KEY)}, + checkpoints=checkpoints) + self.bridge.cctp.circle_session = self.circle = CctpCircle(self) + self.plan = self.bridge.quote(route=self.route, amount='5', sender=SENDER, recipient=RECIPIENT, + cctp={'speed': 'fast', 'forwarding': forwarding, 'max_fee': '0.1'}).plan + self.burned, self.attested = self.message(False), self.message(True) + self.source.receipt_logs = lambda tx: self.source_logs(tx['hash']) if tx['to'].lower() == self.messenger.lower() else [] + self.destination.receipt_logs = lambda tx: self.destination_logs(tx['hash']) + self.saved = [] + self.complete_destination() + + def message(self, attested): + raw = bytearray.fromhex(FIXTURE['attested' if attested else 'source'][2:]) + for start, value in ((4, self.route.metadata['sourceDomain']), (8, self.route.metadata['destinationDomain'])): + raw[start:start+4] = value.to_bytes(4, 'big') + raw[152:184] = bytes.fromhex(self.source_token[2:]).rjust(32, b'\0') + raw[248:280] = bytes.fromhex(SENDER[2:]).rjust(32, b'\0') + return bytes(raw if self.forwarding else raw[:376]) + + def source_logs(self, tx_hash): + return [event_log(self.transmitter, ['0x'+keccak(text='MessageSent(bytes)').hex()], + '0x'+encode(['bytes'], [self.burned]).hex(), log_index=0, tx_hash=tx_hash)] + + def destination_logs(self, tx_hash=DEST_HASH, amount=4_990_000): + topics = ['0x'+keccak(text='MessageReceived(address,uint32,bytes32,bytes32,uint32,bytes)').hex(), + '0x'+encode(['address'], [SENDER]).hex(), '0x'+NONCE.hex(), '0x'+encode(['uint32'], [1000]).hex()] + received = event_log(self.transmitter, topics, + '0x'+encode(['uint32','bytes32','bytes'], [self.route.metadata['sourceDomain'], + bytes.fromhex(self.messenger[2:]).rjust(32,b'\0'), self.attested[148:]]).hex(), + log_index=0, tx_hash=tx_hash) + minted = event_log(self.destination_token, ['0x'+keccak(text='Transfer(address,address,uint256)').hex(), + '0x'+encode(['address'], [ZERO_ADDRESS]).hex(), '0x'+encode(['address'], [RECIPIENT]).hex()], + '0x'+encode(['uint256'], [amount]).hex(), log_index=1, tx_hash=tx_hash) + return [received, minted] + + def complete_destination(self): + self.destination.used = True + self.destination.add_receipt(DEST_HASH, logs=self.destination_logs()) + + def execute(self): + return self.bridge.execute(self.plan, timeout_seconds=0, on_checkpoint=self.saved.append) diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index dd4c299c..590c1b1c 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -345,7 +345,7 @@ def fake_web3(**config: Any) -> Web3: return Web3(FakeRpcProvider(**config)) -def make_bridge(*, ethereum: Any = None, environment: str | None = None, checkpoints: Any = None, +def make_bridge(*, ethereum: Any = None, environment: str | None = None, checkpoints: Any = None, evm: Any = None, **aleo_kwargs: Any) -> Any: """A ``Bridge`` over a fresh ``FakeAleo`` (mainnet unless *environment* says otherwise), wired with *ethereum* so ``bridge.eth`` works. @@ -363,4 +363,4 @@ def make_bridge(*, ethereum: Any = None, environment: str | None = None, checkpo 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) + return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum, environment=environment, checkpoints=checkpoints, evm=evm) diff --git a/bridge-sdk/tests/fixtures/cctp-v2.json b/bridge-sdk/tests/fixtures/cctp-v2.json new file mode 100644 index 00000000..1f533421 --- /dev/null +++ b/bridge-sdk/tests/fixtures/cctp-v2.json @@ -0,0 +1,5 @@ +{ + "_source": "ProvableHQ/veil packages/bridge/test/actions/cctp.test.ts fixture(true,0,ethereum,arc) @ 3c3b457bd5f63620657321893a2487e489750d24; constructed constants ported exactly", + "source": "0x00000001000000000000001a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d0000000000000000000000000000000000000000000000000000000000000000000003e80000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000004c4b40000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000636374702d666f72776172640000000000000000000000000000000000000000", + "attested": "0x00000001000000000000001a000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d00000000000000000000000028b5a0e9c621a5badaa536219b3a228c8168cf5d0000000000000000000000000000000000000000000000000000000000000000000003e8000003e800000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000004c4b40000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000002710000000000000000000000000000000000000000000000000000000003b9ac9ff636374702d666f72776172640000000000000000000000000000000000000000" +} diff --git a/bridge-sdk/tests/fixtures/registry-2026-08-31-python.json b/bridge-sdk/tests/fixtures/registry-2026-08-31-python.json new file mode 100644 index 00000000..875ee599 --- /dev/null +++ b/bridge-sdk/tests/fixtures/registry-2026-08-31-python.json @@ -0,0 +1,1226 @@ +{ + "_source": { + "python_commit": "1c0935dbd6532319f0b5e3967320dbcbe0827a23", + "upstream": "ProvableHQ/veil packages/bridge/src/registry/default.ts @ 8d62198" + }, + "version": "2026-08-31.solana-deposits.1", + "chains": [ + { + "id": "aleo", + "display_name": "Aleo", + "family": "aleo", + "environment": "mainnet", + "native_symbol": "ALEO", + "protocol_domains": { + "xreserve": 10002, + "hyperlane": 1634493807 + } + }, + { + "id": "ethereum", + "display_name": "Ethereum", + "family": "evm", + "environment": "mainnet", + "native_symbol": "ETH", + "protocol_domains": { + "xreserve": 0, + "hyperlane": 1 + } + }, + { + "id": "solana", + "display_name": "Solana", + "family": "solana", + "environment": "mainnet", + "native_symbol": "SOL", + "protocol_domains": { + "hyperlane": 1399811149 + } + }, + { + "id": "base", + "display_name": "Base", + "family": "evm", + "environment": "mainnet", + "native_symbol": "ETH", + "protocol_domains": {} + }, + { + "id": "hyperevm", + "display_name": "HyperEVM", + "family": "evm", + "environment": "mainnet", + "native_symbol": "HYPE", + "protocol_domains": {} + }, + { + "id": "aleo-testnet", + "display_name": "Aleo Testnet", + "family": "aleo", + "environment": "testnet", + "native_symbol": "ALEO", + "protocol_domains": { + "xreserve": 10002, + "hyperlane": 1617853565 + } + }, + { + "id": "sepolia", + "display_name": "Ethereum Sepolia", + "family": "evm", + "environment": "testnet", + "native_symbol": "ETH", + "protocol_domains": { + "hyperlane": 11155111 + } + } + ], + "assets": [ + { + "id": "aleo/aleo", + "key": "aleo", + "chain_id": "aleo", + "symbol": "ALEO", + "name": "Aleo", + "decimals": 6, + "kind": "native", + "locator": { + "kind": "aleo-program", + "value": "credits.aleo", + "token_id": null + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": null + }, + { + "id": "aleo/usdcx", + "key": "usdcx", + "chain_id": "aleo", + "symbol": "USDCx", + "name": "USDCx", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "usdcx_stablecoin.aleo", + "token_id": null + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc22", + "program": "usdcx_stablecoin.aleo" + } + }, + { + "id": "aleo/eth", + "key": "eth", + "chain_id": "aleo", + "symbol": "ETH", + "name": "Hyperlane ETH", + "decimals": 18, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "hyp_warp_token_eth_v2.aleo", + "token_id": "aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8" + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc20", + "program": "arc20_eth.aleo" + } + }, + { + "id": "aleo/wbtc", + "key": "wbtc", + "chain_id": "aleo", + "symbol": "WBTC", + "name": "Hyperlane WBTC", + "decimals": 8, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "hyp_warp_token_wbtc_v2.aleo", + "token_id": "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf" + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc20", + "program": "arc20_wbtc.aleo" + } + }, + { + "id": "aleo/usdt", + "key": "usdt", + "chain_id": "aleo", + "symbol": "USDT", + "name": "Hyperlane USDT", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "hyp_warp_token_usdt_v2.aleo", + "token_id": "aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y" + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc20", + "program": "arc20_usdt.aleo" + } + }, + { + "id": "aleo/sol", + "key": "sol", + "chain_id": "aleo", + "symbol": "SOL", + "name": "Hyperlane SOL", + "decimals": 9, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "hyp_warp_token_sol_v2.aleo", + "token_id": "aleo1aa0zt0vg9uwknekpqeefkvad55swp7833wc5crp2prv0lm4djuxs5r7k6v" + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc20", + "program": "arc20_sol.aleo" + } + }, + { + "id": "aleo/usad", + "key": "usad", + "chain_id": "aleo", + "symbol": "USAD", + "name": "USAD", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "usad_stablecoin.aleo", + "token_id": null + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": null + }, + { + "id": "ethereum/usdc", + "key": "usdc", + "chain_id": "ethereum", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "evm-contract", + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "token_id": null + }, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "ethereum/eth", + "key": "eth", + "chain_id": "ethereum", + "symbol": "ETH", + "name": "Ether", + "decimals": 18, + "kind": "native", + "locator": { + "kind": "native", + "value": "ETH", + "token_id": null + }, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "ethereum/wbtc", + "key": "wbtc", + "chain_id": "ethereum", + "symbol": "WBTC", + "name": "Wrapped Bitcoin", + "decimals": 8, + "kind": "token", + "locator": { + "kind": "evm-contract", + "value": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "token_id": null + }, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "ethereum/usdt", + "key": "usdt", + "chain_id": "ethereum", + "symbol": "USDT", + "name": "Tether USD", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "evm-contract", + "value": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "token_id": null + }, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "ethereum/aleo", + "key": "aleo", + "chain_id": "ethereum", + "symbol": "ALEO", + "name": "Hyperlane ALEO", + "decimals": 6, + "kind": "token", + "locator": null, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "ethereum/usad", + "key": "usad", + "chain_id": "ethereum", + "symbol": "USAD", + "name": "USAD route collateral", + "decimals": 6, + "kind": "token", + "locator": null, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "solana/sol", + "key": "sol", + "chain_id": "solana", + "symbol": "SOL", + "name": "Solana", + "decimals": 9, + "kind": "native", + "locator": { + "kind": "native", + "value": "SOL", + "token_id": null + }, + "address_regex": "^[1-9A-HJ-NP-Za-km-z]{32,44}$", + "privacy": null + }, + { + "id": "solana/aleo", + "key": "aleo", + "chain_id": "solana", + "symbol": "ALEO", + "name": "Hyperlane ALEO", + "decimals": 6, + "kind": "token", + "locator": null, + "address_regex": "^[1-9A-HJ-NP-Za-km-z]{32,44}$", + "privacy": null + }, + { + "id": "base/aleo", + "key": "aleo", + "chain_id": "base", + "symbol": "ALEO", + "name": "Hyperlane ALEO", + "decimals": 6, + "kind": "token", + "locator": null, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "hyperevm/aleo", + "key": "aleo", + "chain_id": "hyperevm", + "symbol": "ALEO", + "name": "Hyperlane ALEO", + "decimals": 6, + "kind": "token", + "locator": null, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + }, + { + "id": "aleo-testnet/usdcx", + "key": "usdcx", + "chain_id": "aleo-testnet", + "symbol": "USDCx", + "name": "Testnet USDCx", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "aleo-program", + "value": "test_usdcx_stablecoin.aleo", + "token_id": null + }, + "address_regex": "^aleo1[0-9a-z]{58}$", + "privacy": { + "kind": "arc22", + "program": "test_usdcx_stablecoin.aleo" + } + }, + { + "id": "sepolia/usdc", + "key": "usdc", + "chain_id": "sepolia", + "symbol": "USDC", + "name": "Testnet USD Coin", + "decimals": 6, + "kind": "token", + "locator": { + "kind": "evm-contract", + "value": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "token_id": null + }, + "address_regex": "^0x[0-9a-fA-F]{40}$", + "privacy": null + } + ], + "routes": [ + { + "id": "xreserve:ethereum/usdc->aleo/usdcx", + "protocol": "xreserve", + "environment": "mainnet", + "source_asset_id": "ethereum/usdc", + "destination_asset_id": "aleo/usdcx", + "availability": "active", + "deployment_id": "xreserve-usdcx-aleo", + "source": "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains", + "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" + } + }, + { + "id": "xreserve:aleo/usdcx->ethereum/usdc", + "protocol": "xreserve", + "environment": "mainnet", + "source_asset_id": "aleo/usdcx", + "destination_asset_id": "ethereum/usdc", + "availability": "active", + "deployment_id": "xreserve-usdcx-aleo", + "source": "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains", + "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" + } + }, + { + "id": "xreserve:sepolia/usdc->aleo-testnet/usdcx", + "protocol": "xreserve", + "environment": "testnet", + "source_asset_id": "sepolia/usdc", + "destination_asset_id": "aleo-testnet/usdcx", + "availability": "active", + "deployment_id": "xreserve-usdcx-aleo-testnet", + "source": "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains", + "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" + } + }, + { + "id": "xreserve:aleo-testnet/usdcx->sepolia/usdc", + "protocol": "xreserve", + "environment": "testnet", + "source_asset_id": "aleo-testnet/usdcx", + "destination_asset_id": "sepolia/usdc", + "availability": "active", + "deployment_id": "xreserve-usdcx-aleo-testnet", + "source": "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains", + "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" + } + }, + { + "id": "hyperlane:ethereum/eth->aleo/eth", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "ethereum/eth", + "destination_asset_id": "aleo/eth", + "availability": "active", + "deployment_id": "ETH/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "routerType": "native", + "destinationRouter": "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/eth->ethereum/eth", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/eth", + "destination_asset_id": "ethereum/eth", + "availability": "active", + "deployment_id": "ETH/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "routerType": "native", + "destinationRouter": "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8", + "aleoRouterProgram": "hyp_warp_token_eth_v2.aleo", + "aleoDestinationDomain": 1, + "aleoPlaceholderConfiguration": false, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r", + "aleoIsm": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoHook": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoTokenId": "133188123661477349522757068766864658505569365361420630212878794317749195359field", + "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", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount3": "0", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", + "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, + "aleoLocalDecimals": 18, + "aleoRemoteDecimals": 18, + "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", + "aleoRemoteRouterEvmAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "aleoAllowanceSpendersVerified": true, + "aleoUnusedAllowancesVerified": true, + "aleoWithdrawalReviewedAt": "2026-08-26" + } + }, + { + "id": "hyperlane:ethereum/wbtc->aleo/wbtc", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "ethereum/wbtc", + "destination_asset_id": "aleo/wbtc", + "availability": "active", + "deployment_id": "WBTC/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "routerType": "collateral", + "tokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "destinationRouter": "hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/wbtc->ethereum/wbtc", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/wbtc", + "destination_asset_id": "ethereum/wbtc", + "availability": "active", + "deployment_id": "WBTC/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "routerType": "collateral", + "tokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "destinationRouter": "hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf", + "aleoRouterProgram": "hyp_warp_token_wbtc_v2.aleo", + "aleoDestinationDomain": 1, + "aleoPlaceholderConfiguration": false, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95", + "aleoIsm": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoHook": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoTokenId": "1505227928464760254508513036497943623956572091841806589002910775534260084309field", + "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", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount3": "0", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", + "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, + "aleoLocalDecimals": 8, + "aleoRemoteDecimals": 8, + "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", + "aleoRemoteRouterEvmAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "aleoAllowanceSpendersVerified": true, + "aleoUnusedAllowancesVerified": true, + "aleoWithdrawalReviewedAt": "2026-08-26" + } + }, + { + "id": "hyperlane:ethereum/usdt->aleo/usdt", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "ethereum/usdt", + "destination_asset_id": "aleo/usdt", + "availability": "active", + "deployment_id": "USDT/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "routerType": "collateral", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "destinationRouter": "hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y", + "requiresApprovalReset": true, + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/usdt->ethereum/usdt", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/usdt", + "destination_asset_id": "ethereum/usdt", + "availability": "active", + "deployment_id": "USDT/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": "2621c16f2db1ccb46643265c110dac5ca2c7c51a", + "routerAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "routerType": "collateral", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "destinationRouter": "hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y", + "requiresApprovalReset": true, + "aleoRouterProgram": "hyp_warp_token_usdt_v2.aleo", + "aleoDestinationDomain": 1, + "aleoPlaceholderConfiguration": false, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z", + "aleoIsm": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoHook": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoTokenId": "8295938150000417034830036849466229528602563851235385582732969109393809606969field", + "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", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount3": "0", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", + "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, + "aleoLocalDecimals": 6, + "aleoRemoteDecimals": 18, + "aleoScale": "1000000000000", + "aleoHyperlaneConfigSource": "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/USDT/aleo-config.yaml", + "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, + "aleoRemoteRouterEvmAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "aleoAllowanceSpendersVerified": true, + "aleoUnusedAllowancesVerified": true, + "aleoWithdrawalReviewedAt": "2026-08-26" + } + }, + { + "id": "hyperlane:solana/sol->aleo/sol", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "solana/sol", + "destination_asset_id": "aleo/sol", + "availability": "active", + "deployment_id": "SOL/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/sol->solana/sol", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/sol", + "destination_asset_id": "solana/sol", + "availability": "active", + "deployment_id": "SOL/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "aleoRouterProgram": "hyp_warp_token_sol_v2.aleo", + "aleoDestinationDomain": 1399811149, + "aleoPlaceholderConfiguration": false, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45", + "aleoIsm": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoHook": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoTokenId": "6148061383892805373029428966764338809222769879628268522058032128225601478383field", + "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", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc", + "aleoAllowanceAmount3": "0", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", + "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, + "aleoLocalDecimals": 9, + "aleoRemoteDecimals": 9, + "aleoHyperlaneConfigSource": "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml", + "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", + "aleoRemoteRouterSolanaAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "aleoAllowanceSpendersVerified": true, + "aleoUnusedAllowancesVerified": true, + "aleoWithdrawalReviewedAt": "2026-08-26" + } + }, + { + "id": "hyperlane:aleo/aleo->ethereum/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/aleo", + "destination_asset_id": "ethereum/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:ethereum/aleo->aleo/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "ethereum/aleo", + "destination_asset_id": "aleo/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/aleo->solana/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/aleo", + "destination_asset_id": "solana/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:solana/aleo->aleo/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "solana/aleo", + "destination_asset_id": "aleo/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/aleo->base/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/aleo", + "destination_asset_id": "base/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:base/aleo->aleo/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "base/aleo", + "destination_asset_id": "aleo/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/aleo->hyperevm/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/aleo", + "destination_asset_id": "hyperevm/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:hyperevm/aleo->aleo/aleo", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "hyperevm/aleo", + "destination_asset_id": "aleo/aleo", + "availability": "metadata-required", + "deployment_id": "ALEO/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:ethereum/usad->aleo/usad", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "ethereum/usad", + "destination_asset_id": "aleo/usad", + "availability": "metadata-required", + "deployment_id": "USAD/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + }, + { + "id": "hyperlane:aleo/usad->ethereum/usad", + "protocol": "hyperlane", + "environment": "mainnet", + "source_asset_id": "aleo/usad", + "destination_asset_id": "ethereum/usad", + "availability": "metadata-required", + "deployment_id": "USAD/aleo", + "source": "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes", + "metadata": { + "aleoRouterProgram": "hyp_warp_token_usad_v2.aleo", + "aleoDestinationDomain": 1, + "aleoPlaceholderConfiguration": true, + "aleoTokenType": "0", + "aleoTokenOwner": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoIsm": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoHook": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoTokenId": "0field", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8]", + "aleoRemoteRouterGas": "0", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n", + "aleoAllowanceAmount3": "0", + "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": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8" + } + } + ] +} diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py index b77ff1f8..f27d55e3 100644 --- a/bridge-sdk/tests/live/cases.py +++ b/bridge-sdk/tests/live/cases.py @@ -65,6 +65,8 @@ def private_mint(self) -> bool: CASES: dict[str, CaseSpec] = { + "evm-cctp": CaseSpec(name="evm-cctp", protocol="cctp", source_family="evm", amount="2", + veil_source="mainnet/cctp-roundtrip.live.test.ts"), "evm-hyperlane": CaseSpec( name="evm-hyperlane", protocol="hyperlane", source_family="evm", veil_source="mainnet/evm-hyperlane.live.test.ts:21-115"), @@ -136,7 +138,8 @@ def sender_for(bridge: Any, route: Route) -> str | None: 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 + chain = bridge.registry.asset(route.source_asset_id).chain_id + connection = (bridge.evm(chain).conn if chain not in ("ethereum", "sepolia") else 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 @@ -148,7 +151,8 @@ def default_recipient(bridge: Any, route: Route) -> str: 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 + chain = bridge.registry.asset(route.destination_asset_id).chain_id + connection = (bridge.evm(chain).conn if chain not in ("ethereum", "sepolia") else 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 @@ -177,7 +181,8 @@ def print_quote(bridge: Any, quote: Any, *, case: str, route_id: str, log: Calla # 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]") + label = "CCTP max fee" if quote.kind == "evm-cctp" else "xReserve max fee" + log(f" fee {_human(bridge, max_fee, source.id)} [{label}]") 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"): @@ -243,6 +248,8 @@ def precheck(bridge: Any, quote: Any, *, case: str, log: Callable[[str], None] = 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 == "evm-cctp": + _require(balances, source.id, plan.amount_atomic, log=log) elif quote.kind == "aleo-xreserve": pass # the private record is selected (and checked) right before the burn return balances @@ -392,7 +399,8 @@ def _evm_transfer_tx(bridge: Any, asset: Asset, recipient: str, amount_atomic: i log range (or returns nothing) leaves ``destinationTxId`` unset rather than failing a leg whose funds have demonstrably arrived. """ - connection = getattr(bridge, "ethereum", None) + connection = (bridge.evm(asset.chain_id).conn if asset.chain_id not in ("ethereum", "sepolia") + else getattr(bridge, "ethereum", None)) if connection is None or asset.locator.kind != "evm-contract": return None try: diff --git a/bridge-sdk/tests/live/config.py b/bridge-sdk/tests/live/config.py index 6f1ee9e5..93f8c0b3 100644 --- a/bridge-sdk/tests/live/config.py +++ b/bridge-sdk/tests/live/config.py @@ -24,7 +24,7 @@ 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") +CASE_NAMES = ("evm-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") FUNDS_VAR = "BRIDGE_LIVE_FUNDS" STATE_DIR_VAR = "BRIDGE_LIVE_STATE_DIR" diff --git a/bridge-sdk/tests/live/helpers.py b/bridge-sdk/tests/live/helpers.py index 229f3814..716bb1c4 100644 --- a/bridge-sdk/tests/live/helpers.py +++ b/bridge-sdk/tests/live/helpers.py @@ -420,7 +420,13 @@ def build_bridge(environment: str) -> Any: 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) + evm = {} + if environment == "mainnet": + for chain in ("arc", "base", "arbitrum"): + url = os.environ.get(f"{chain.upper()}_RPC_URL") + if url: + evm[chain] = Ethereum(url, private_key=live_config.evm_private_key(environment)) + bridge = Bridge(aleo, ethereum=ethereum, evm=evm, solana=solana, checkpoints=store) assert bridge.environment == environment return bridge diff --git a/bridge-sdk/tests/live/test_arc_reads.py b/bridge-sdk/tests/live/test_arc_reads.py new file mode 100644 index 00000000..d3888a77 --- /dev/null +++ b/bridge-sdk/tests/live/test_arc_reads.py @@ -0,0 +1,61 @@ +"""Keyless deployment and fee checks; BRIDGE_LIVE_READS=1 explicitly enables them.""" +import os + +import pytest +from web3 import Web3 + +from aleo_bridge import Bridge, Ethereum +from aleo_bridge._cctp_abi import function +from aleo_bridge._cctp_message import address_bytes +from aleo_bridge.registry import DEFAULT_REGISTRY + +pytestmark = [pytest.mark.live, pytest.mark.slow, + pytest.mark.skipif(os.environ.get('BRIDGE_LIVE_READS') != '1', reason='set BRIDGE_LIVE_READS=1')] + + +def readonly(chains): + from aleo import Aleo, HTTPProvider + evm = {} + for chain in chains: + url = os.environ.get(f'{chain.upper()}_RPC_URL') + if not url: + pytest.skip(f'set {chain.upper()}_RPC_URL') + evm[chain] = Ethereum(url) + return Bridge(Aleo(HTTPProvider('https://edge.provable.com/api',network='mainnet')),evm=evm) + + +@pytest.mark.parametrize('other', ['ethereum','base','arbitrum']) +def test_cctp_domains_reciprocal_messengers_and_fees(other): + bridge = readonly(('arc',other)) + route = DEFAULT_REGISTRY.route(f'cctp:{other}/usdc->arc/usdc') + for chain, domain, remote in [('arc',26,route.meta_int('sourceDomain')),(other,route.meta_int('sourceDomain'),26)]: + conn = bridge.evm(chain).conn + expected = 5042 if chain == 'arc' else route.meta_int('sourceChainId') + assert conn.chain_id == expected + transmitter = conn.w3.eth.contract(address=Web3.to_checksum_address(route.meta_str('messageTransmitter')), + abi=[function('localDomain',[],['uint32'],True)]) + assert transmitter.functions.localDomain().call() == domain + messenger = conn.w3.eth.contract(address=Web3.to_checksum_address(route.meta_str('tokenMessenger')), + abi=[function('remoteTokenMessengers',[('domain','uint32')],['bytes32'],True)]) + assert bytes(messenger.functions.remoteTokenMessengers(remote).call()) == address_bytes(messenger.address) + asset = DEFAULT_REGISTRY.asset(f'{chain}/usdc') + token = conn.w3.eth.contract(address=Web3.to_checksum_address(asset.locator.value), + abi=[function('decimals',[],['uint8'],True)]) + assert token.functions.decimals().call() == 6 + for src,dst in [(other,'arc'),('arc',other)]: + quote = bridge.quote(source_chain=src,source_asset='usdc',destination_chain=dst,amount='5', + recipient='0x0000000000000000000000000000000000000022') + assert 0 <= quote.max_fee_atomic < quote.amount_atomic + assert quote.amount_out_atomic == quote.amount_atomic-quote.max_fee_atomic + + +def test_arc_xreserve_deployment_and_live_withdrawal_fee(): + bridge = readonly(('arc',)) + route = DEFAULT_REGISTRY.route('xreserve:arc/usdc->aleo/usdcx') + conn = bridge.evm('arc').conn + assert conn.chain_id == 5042 + assert len(conn.w3.eth.get_code(Web3.to_checksum_address(route.meta_str('xReserveContract')))) > 0 + quote = bridge.quote(source_chain='aleo',source_asset='usdcx',destination_chain='arc',amount='5', + recipient='0x0000000000000000000000000000000000000022') + assert quote.status == 'quoted' + assert 0 <= quote.withdrawal_fee_atomic < 5_000_000 diff --git a/bridge-sdk/tests/live/test_arc_xreserve.py b/bridge-sdk/tests/live/test_arc_xreserve.py new file mode 100644 index 00000000..917759f5 --- /dev/null +++ b/bridge-sdk/tests/live/test_arc_xreserve.py @@ -0,0 +1,120 @@ +"""Named Arc xReserve cases, using the existing funded gates and recovery engine.""" +import pytest +import json +import requests +from web3 import Web3 +from eth_utils.crypto import keccak +from aleo_bridge import Bridge, Ethereum +from aleo_bridge._cctp_abi import TOKEN_ABI +from aleo_bridge.errors import BridgeError +from aleo_bridge.privacy import record_amount +from aleo_bridge.client import build_aleo +from aleo_bridge.registry import DEFAULT_REGISTRY +from .test_lifecycle_live import _mainnet, mainnet_client, _register_record_scanner +from . import config +from .helpers import wait_for, wait_for_aleo_transaction +from .test_cctp_roundtrip import workflow_gate +from _arc_workflow import _save + +pytestmark = [pytest.mark.live, pytest.mark.slow] + + +def test_arc_to_aleo_private_mint(mainnet_client,record_property): + _mainnet('evm-xreserve',DEFAULT_REGISTRY.route('xreserve:arc/usdc->aleo/usdcx'),mainnet_client,record_property) + + +def exact_arc_delivery(conn, *, recipient, start_block, before, expected): + """Independent live-test proof: recent exact transfer, successful receipt, exact balance delta.""" + token = conn.w3.eth.contract(address='0x3600000000000000000000000000000000000000',abi=TOKEN_ABI) + topics = ['0x'+keccak(text='Transfer(address,address,uint256)').hex(),None, + '0x'+bytes.fromhex(recipient[2:]).rjust(32,b'\0').hex()] + head = conn.w3.eth.block_number + for start in range(start_block,head+1,1000): + for log in conn.w3.eth.get_logs({'address':token.address,'topics':topics, + 'fromBlock':start,'toBlock':min(head,start+999)}): + if not log['topics'] or Web3.to_hex(log['topics'][0]) != topics[0]: + continue + event = token.events.Transfer().process_log(log)['args'] + if event['to'].lower() != recipient.lower() or event['value'] != expected: + continue + tx_hash = Web3.to_hex(log['transactionHash']) + receipt = conn.get_receipt(tx_hash) + if receipt is None: + continue + assert receipt['status'] == 1 and Web3.to_hex(receipt['transactionHash']) == tx_hash + matching = False + for entry in receipt['logs']: + if (entry['address'].lower() != token.address.lower() or not entry['topics'] + or Web3.to_hex(entry['topics'][0]) != topics[0]): + continue + decoded = token.events.Transfer().process_log(entry)['args'] + matching |= decoded['to'].lower() == recipient.lower() and decoded['value'] == expected + assert matching, 'Destination receipt must contain the exact token transfer' + assert token.functions.balanceOf(Web3.to_checksum_address(recipient)).call()-before == expected + return tx_hash + return None + + +class BudgetedFeeSession: + def __init__(self,state,path,session=None): + self.state,self.path,self.session = state,path,session or requests.Session() + + def post(self,*args,**kwargs): + response = self.session.post(*args,**kwargs) + if response.status_code == 200: + fee = response.json().get('withdrawalFeeBaseUnits') + if not isinstance(fee,str) or not fee.isascii() or not fee.isdigit() or int(fee)>100_000: + raise BridgeError('Withdrawal estimate exceeds the live-test 0.10-USDC fee budget') + self.state['expected_atomic'] = 2_000_000-int(fee) + if self.state.get('started'): + _save(self.path,self.state) + return response + + +def test_aleo_private_burn_to_arc(record_property): + root,execute = workflow_gate('aleo-arc') + path = root/'withdrawal.json' + state = json.loads(path.read_text()) if path.exists() else {} + route = DEFAULT_REGISTRY.route('xreserve:aleo/usdcx->arc/usdc') + recipient = config.required('BRIDGE_LIVE_ARC_RECIPIENT') + arc = Ethereum(config.required('ARC_RPC_URL')) # No destination signer is created. + assert arc.chain_id == 5042 and not arc.can_sign + aleo = build_aleo(config.aleo_endpoint(),'mainnet',config.aleo_private_key('mainnet')) + bridge = Bridge(aleo,evm={'arc':arc}) + bridge.xreserve.circle_session = BudgetedFeeSession(state,path) + if state and (state.get('route') != route.id or state.get('recipient') != recipient): + raise BridgeError('Saved withdrawal belongs to a different intent') + def save(cp): + state['checkpoint'] = cp.to_dict() + state['source_tx_id'] = (cp.source or {}).get('transactionId') + _save(path,state) + if state.get('checkpoint'): + progress = bridge.recover(state['checkpoint']) + if progress.next == 'resume': + if not execute: + return + bridge.resume(progress,on_checkpoint=save) + elif progress.next == 'failed': + raise BridgeError('Saved Aleo burn failed') + else: + if state.get('started'): + raise BridgeError('Burn started without checkpoint; inspect source history before retrying') + quote = bridge.quote(route=route,amount='2',recipient=recipient,sender=bridge.aleo_address()) + assert state['expected_atomic'] >= 1_900_000 + if not execute: + return + _register_record_scanner(bridge,'mainnet') + record = bridge.privacy.select_record(route.meta_str('remoteToken'),2_000_000) + assert record_amount(record) == 2_000_000, 'Use an unspent two-USDCx record' + proof = bridge.freezelist.exclusion_proof(bridge.aleo_address(),route.meta_str('remoteToken')) + state.update(route=route.id,recipient=recipient,started=True,start_block=arc.w3.eth.block_number, + before=bridge.evm('arc').balance('arc/usdc',address=recipient)) + _save(path,state) + bridge.execute(quote.plan,mode='private',record=record,merkle_proof=proof,on_checkpoint=save) + assert state.get('source_tx_id') + wait_for_aleo_transaction(bridge,state['source_tx_id']) + tx_hash = wait_for(lambda: exact_arc_delivery(arc,recipient=recipient,start_block=state['start_block'], + before=state['before'],expected=state['expected_atomic'])) + state.update(destination_tx_id=tx_hash,done=True) + _save(path,state) + record_property('destination_tx_id',tx_hash) diff --git a/bridge-sdk/tests/live/test_cctp_roundtrip.py b/bridge-sdk/tests/live/test_cctp_roundtrip.py new file mode 100644 index 00000000..0fec4879 --- /dev/null +++ b/bridge-sdk/tests/live/test_cctp_roundtrip.py @@ -0,0 +1,52 @@ +"""Funded received-only roundtrips. No gate is set or inferred by this module.""" +from pathlib import Path +import sys + +import pytest +from aleo_bridge.units import format_decimal_amount +from . import config +from .helpers import build_bridge + +sys.path.insert(0,str(Path(__file__).parents[2]/'examples')) +from _arc_workflow import run_leg, spendable + +pytestmark = [pytest.mark.live, pytest.mark.slow] + + +def workflow_gate(case): + if not config.mainnet_case_enabled(case): + pytest.skip(f'enable the existing live-funds/state/mainnet gates and case {case}') + return config.state_dir()/'mainnet'/case, config.mainnet_execution_enabled() + + +@pytest.mark.parametrize('other',['ethereum','base','arbitrum']) +def test_cctp_received_only_roundtrip(other): + root, execute = workflow_gate('cctp-roundtrip') + bridge = build_bridge('mainnet') + address = bridge.evm(other).conn.require_address() + first = run_leg(bridge,route=f'cctp:{other}/usdc->arc/usdc',amount='5',recipient=address,sender=address, + state_path=root/other/'out.json',execute=execute,timeout=1200) + if not execute: + return + assert first is not None and 0 < first <= 5_000_000 + returned = run_leg(bridge,route=f'cctp:arc/usdc->{other}/usdc',amount=spendable(first),recipient=address, + sender=address,state_path=root/other/'return.json',execute=True,timeout=1200) + assert returned is not None and 0 < returned < first + + +@pytest.mark.parametrize('other',['base','arbitrum']) +def test_public_l2_arc_aleo_four_leg_journey(other): + root, execute = workflow_gate('arc-journey') + bridge = build_bridge('mainnet') + evm = bridge.evm(other).conn.require_address() + aleo = bridge.aleo_address() + legs = [(f'cctp:{other}/usdc->arc/usdc',evm,evm),('xreserve:arc/usdc->aleo/usdcx',evm,aleo), + ('xreserve:aleo/usdcx->arc/usdc',aleo,evm),(f'cctp:arc/usdc->{other}/usdc',evm,evm)] + amount = '5' + for i,(route,sender,recipient) in enumerate(legs): + received = run_leg(bridge,route=route,amount=amount,recipient=recipient,sender=sender, + state_path=root/other/f'leg-{i}.json',execute=execute,timeout=1200) + if not execute: + return + assert received is not None and received > 0 + amount = spendable(received) if i in (0,2) else format_decimal_amount(received,6) diff --git a/bridge-sdk/tests/live/test_lifecycle_live.py b/bridge-sdk/tests/live/test_lifecycle_live.py index 6db50854..2008a4dd 100644 --- a/bridge-sdk/tests/live/test_lifecycle_live.py +++ b/bridge-sdk/tests/live/test_lifecycle_live.py @@ -293,6 +293,12 @@ def test_evm_xreserve(route, mainnet_client, record_property): _mainnet("evm-xreserve", route, mainnet_client, record_property) +@_params("evm-cctp") +def test_evm_cctp(route, mainnet_client, record_property): + """Native USDC on all six Arc CCTP directions, with persisted recovery.""" + _mainnet("evm-cctp", 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"`.""" diff --git a/bridge-sdk/tests/test_arc_examples.py b/bridge-sdk/tests/test_arc_examples.py new file mode 100644 index 00000000..e52e2995 --- /dev/null +++ b/bridge-sdk/tests/test_arc_examples.py @@ -0,0 +1,111 @@ +import importlib +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest +from aleo_bridge.errors import BridgeError +from tests.fakes.fake_cctp import Harness, RECIPIENT + +sys.path.insert(0,str(Path(__file__).parents[1]/'examples')) + + +def test_interrupted_leg_recovers_checkpoint_without_executing_again(tmp_path): + workflow = importlib.import_module('_arc_workflow') + h = Harness() + real_execute = h.bridge.execute + def interrupted(*args, **kwargs): + real_execute(*args, **kwargs) + raise RuntimeError('interrupted after checkpoint') + h.bridge.execute = interrupted + kwargs = dict(route=h.route.id, amount='5', recipient=RECIPIENT, state_path=tmp_path/'leg.json', execute=True, + cctp={'speed':'fast','max_fee':'0.1'}) + with pytest.raises(RuntimeError): workflow.run_leg(h.bridge, **kwargs) + h.bridge.execute = Mock(side_effect=AssertionError('duplicate burn')) + assert workflow.run_leg(h.bridge, **kwargs) == 4_990_000 + h.bridge.execute.assert_not_called() + assert workflow.run_leg(h.bridge, **kwargs) == 4_990_000 + + +def test_arc_reserve_is_integer_and_cannot_exhaust_receipts(): + workflow = importlib.import_module('_arc_workflow') + assert workflow.spendable(4_990_000,'0.10') == '4.89' + for reserve in ('5','-1','0.1000001'): + with pytest.raises((BridgeError, ValueError)): workflow.spendable(4_990_000,reserve) + + +def test_provider_handoff_never_claims_received_funds(tmp_path): + workflow = importlib.import_module('_arc_workflow') + h = Harness() + h.circle.forward_hash = None + h.destination.used = False + with pytest.raises(BridgeError): + workflow.run_leg(h.bridge,route=h.route.id,amount='5',recipient=RECIPIENT, + state_path=tmp_path/'leg.json',execute=True,timeout=0, + cctp={'speed':'fast','max_fee':'0.1'}) + assert (tmp_path/'leg.json').exists() + assert len(h.source.sent) == 1 + + +def test_xreserve_repricing_returns_observed_net_not_initial_quote(tmp_path, monkeypatch): + from types import SimpleNamespace + from aleo_bridge import Receipt, Status + from aleo_bridge.lifecycle import prepare + from aleo_bridge.types import to_progress + from aleo_bridge.registry import DEFAULT_REGISTRY + workflow = importlib.import_module('_arc_workflow') + route = 'xreserve:aleo/usdcx->arc/usdc' + plan = prepare(DEFAULT_REGISTRY,route=route,amount='5',recipient=RECIPIENT) + receipt = Receipt('at1burn','xreserve',Status.COMPLETED,source_tx_id='at1burn', + protocol_state={'routeId':route,'expectedDestinationIncreaseAtomic':'4980000', + 'destinationBalanceBeforeAtomic':'10000000'}) + bridge = Mock(registry=DEFAULT_REGISTRY) + bridge.quote.return_value = SimpleNamespace(plan=plan,amount_out='4.99',fees=[]) + bridge.execute.return_value = to_progress(plan,receipt) + balances = iter([10_000_000,14_980_000]) + monkeypatch.setattr(workflow,'_balance',lambda *args: next(balances),raising=False) + assert workflow.run_leg(bridge,route=route,amount='5',recipient=RECIPIENT, + state_path=tmp_path/'leg.json',execute=True) == 4_980_000 + + +@pytest.mark.parametrize('execute',[False,True]) +@pytest.mark.parametrize('module_name',['bridge_arc_to_aleo','bridge_ethereum_arc_aleo']) +def test_arc_example_commands_preview_and_execute(module_name,execute,monkeypatch,tmp_path): + module = importlib.import_module(module_name) + build = Mock(return_value=Mock()) + run = Mock(return_value=4_990_000 if execute else None) + monkeypatch.setattr(module,'build_bridge',build) + monkeypatch.setattr(module,'run_leg',run) + args = ['--sender',RECIPIENT,'--recipient','aleo1recipient','--journal',str(tmp_path)] + if execute: args.append('--execute') + assert module.main(args) == 0 + assert all(c.kwargs['execute'] is execute for c in run.call_args_list) + assert run.call_count == (2 if execute and module_name == 'bridge_ethereum_arc_aleo' else 1) + if run.call_count == 2: + assert run.call_args.kwargs['amount'] == '4.89' + + +@pytest.mark.parametrize('l2',['base','arbitrum']) +@pytest.mark.parametrize('step',[1,2,3,4]) +@pytest.mark.parametrize('execute',[False,True]) +def test_each_l2_roundtrip_step_uses_previous_received_budget(l2,step,execute,monkeypatch,tmp_path): + import json + module = importlib.import_module('l2_arc_aleo_roundtrip') + routes = [f'cctp:{l2}/usdc->arc/usdc','xreserve:arc/usdc->aleo/usdcx', + 'xreserve:aleo/usdcx->arc/usdc',f'cctp:arc/usdc->{l2}/usdc'] + if step > 1: + root = tmp_path/l2 + root.mkdir() + (root/f'leg-{step-1}.json').write_text(json.dumps({'done':True,'received_atomic':4_990_000, + 'request':{'route':routes[step-2]}})) + monkeypatch.setattr(module,'build_bridge',Mock(return_value=Mock())) + run = Mock(return_value=4_000_000 if execute else None) + monkeypatch.setattr(module,'run_leg',run) + args = ['--sender',RECIPIENT,'--recipient','aleo1recipient','--journal',str(tmp_path), + '--l2',l2,'--step',str(step)] + if execute: args.append('--execute') + assert module.main(args) == 0 + run.assert_called_once() + assert run.call_args.kwargs['route'] == routes[step-1] + assert run.call_args.kwargs['execute'] is execute + assert run.call_args.kwargs['amount'] == ('5' if step == 1 else '4.89' if step in (2,4) else '4.99') diff --git a/bridge-sdk/tests/test_arc_live_gates.py b/bridge-sdk/tests/test_arc_live_gates.py new file mode 100644 index 00000000..133b6d07 --- /dev/null +++ b/bridge-sdk/tests/test_arc_live_gates.py @@ -0,0 +1,56 @@ +import pytest +from tests.live import config + + +@pytest.mark.parametrize('case',['cctp-roundtrip','arc-journey','aleo-arc','evm-cctp','evm-xreserve','aleo-xreserve']) +def test_every_arc_funded_case_needs_all_gates(case): + env = {config.FUNDS_VAR:'1',config.STATE_DIR_VAR:'/tmp/arc-live-state', + config.MAINNET_ACK_VAR:config.MAINNET_ACK,config.MAINNET_CASES_VAR:case} + assert config.mainnet_case_enabled(case,env) + assert not config.mainnet_execution_enabled(env) + for required in list(env): + missing = {k:v for k,v in env.items() if k != required} + assert not config.mainnet_case_enabled(case,missing) + assert config.mainnet_execution_enabled({**env,config.MAINNET_EXECUTE_VAR:config.MAINNET_EXECUTE_ACK}) + + +@pytest.mark.parametrize('failure',[None,'wrong_amount','old_block','reverted','wrong_balance','unrelated_event']) +def test_arc_live_delivery_requires_exact_recent_successful_mint(failure): + from tests.live.test_arc_xreserve import exact_arc_delivery + from tests.fakes.fake_cctp import Harness,RECIPIENT,DEST_HASH + from web3 import Web3 + h = Harness() + log = h.destination_logs(amount=1 if failure == 'wrong_amount' else 4_990_000)[1] + log['blockNumber'] = hex(10 if failure == 'old_block' else 100) + h.destination.history_logs = [log] + h.destination.receipts[DEST_HASH]['logs'] = [log] + if failure == 'unrelated_event': + from tests.fakes.fake_web3 import event_log + from eth_utils.crypto import keccak + other = event_log(h.destination_token,['0x'+keccak(text='Mint(address,uint256)').hex()], + '0x',log_index=2,tx_hash=DEST_HASH) + h.destination.receipts[DEST_HASH]['logs'].insert(0,other) + if failure == 'reverted': h.destination.receipts[DEST_HASH]['status'] = '0x0' + key = (Web3.to_checksum_address(h.destination_token),Web3.to_checksum_address(RECIPIENT)) + h.destination.token_balances[key] = 4_990_000 if failure != 'wrong_balance' else 1 + conn = h.bridge.evm('arc').conn + if failure in ('reverted','wrong_balance'): + with pytest.raises(AssertionError): exact_arc_delivery(conn,recipient=RECIPIENT,start_block=50,before=0,expected=4_990_000) + else: + result = exact_arc_delivery(conn,recipient=RECIPIENT,start_block=50,before=0,expected=4_990_000) + assert result == (DEST_HASH if failure in (None,'unrelated_event') else None) + + +def test_arc_live_fee_refresh_preserves_budgeted_net_amount(tmp_path): + import json + from unittest.mock import Mock + from tests.live.test_arc_xreserve import BudgetedFeeSession + from aleo_bridge.errors import BridgeError + state = {'started':True} + response = Mock(status_code=200) + response.json.return_value = {'withdrawalFeeBaseUnits':'20000'} + session = BudgetedFeeSession(state,tmp_path/'state.json',Mock(post=Mock(return_value=response))) + session.post('unused') + assert json.loads((tmp_path/'state.json').read_text())['expected_atomic'] == 1_980_000 + response.json.return_value = {'withdrawalFeeBaseUnits':'100001'} + with pytest.raises(BridgeError): session.post('unused') diff --git a/bridge-sdk/tests/test_arc_xreserve.py b/bridge-sdk/tests/test_arc_xreserve.py new file mode 100644 index 00000000..9e0db487 --- /dev/null +++ b/bridge-sdk/tests/test_arc_xreserve.py @@ -0,0 +1,113 @@ +"""Arc xReserve binds the selected domain and rechecks live withdrawal coverage.""" +from dataclasses import replace + +import pytest +from eth_account import Account + +from aleo_bridge import Ethereum +from aleo_bridge.errors import BridgeError, ConfigurationError, InvalidAmountError +from aleo_bridge.registry import DEFAULT_REGISTRY +from tests.fakes.fake_web3 import fake_web3, make_bridge +from tests.test_eth_xreserve_quote import ALEO, KEY, MAINNET_XRESERVE + +ARC_TOKEN = '0x3600000000000000000000000000000000000000' +OUT = DEFAULT_REGISTRY.route('xreserve:aleo/usdcx->arc/usdc') +IN = DEFAULT_REGISTRY.route('xreserve:arc/usdc->aleo/usdcx') + + +class FeeSession: + def __init__(self, fee='16400', status=200): + self.fee, self.status_code, self.calls = fee, status, [] + + def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self + + def json(self): + return {'withdrawalFeeBaseUnits': self.fee} + + +@pytest.mark.parametrize('mode,index', [('private', 2), ('public', 1), ('public-as-signer', 1)]) +def test_arc_burn_uses_domain_26(mode, index): + b = make_bridge() + _, _, inputs = b.xreserve.build_burn_inputs(OUT, mode=mode, amount_atomic=2_000_000, + recipient='0x'+'11'*20, record='record', merkle_proof='[proof]') + assert inputs[index] == '26u32' + + +def test_minimum_burn_even_when_fee_is_smaller(): + with pytest.raises(InvalidAmountError, match='minimum'): + make_bridge().xreserve.build_burn_inputs(OUT, mode='public', amount_atomic=1_999_999, + recipient='0x'+'11'*20, record=None, merkle_proof=None) + + +def test_quote_uses_live_fee_and_selected_route(): + b = make_bridge() + b.xreserve.circle_session = session = FeeSession() + q = b.quote(route=OUT, amount='2', recipient='0x'+'11'*20) + assert q.status == 'quoted' and q.withdrawal_fee_atomic == 16400 and q.amount_out == '1.9836' + assert session.calls[0][1]['json'] == {'evmChain': 'arc', 'amountUsdc': '2'} + + +@pytest.mark.parametrize('fee,status', [(True, 200), ('-1', 200), (None, 200), ('1.5', 200), ('2000000', 200), ('16400', 503)]) +def test_bad_or_unaffordable_live_fee_refuses_quote(fee, status): + b = make_bridge() + b.xreserve.circle_session = FeeSession(fee, status) + with pytest.raises(BridgeError): + b.quote(route=OUT, amount='2', recipient='0x'+'11'*20) + + +def test_execute_rechecks_fee_before_proving(monkeypatch): + b = make_bridge() + b.xreserve.circle_session = session = FeeSession() + q = b.quote(route=OUT, amount='2', recipient='0x'+'11'*20) + session.fee = '2000000' + def fail_prove(*args, **kwargs): + pytest.fail('Unaffordable burn reached proving') + monkeypatch.setattr(b, '_call', fail_prove) + with pytest.raises(InvalidAmountError): + b.execute(q.plan, mode='public') + assert len(session.calls) == 2 + + +@pytest.mark.parametrize('mode', ['public', 'record', 'private']) +def test_arc_deposit_preserves_mint_modes(mode): + address = Account.from_key(KEY).address + w3 = fake_web3(chain_id=5042, token_balances={(ARC_TOKEN, address): 3_000_000}, + allowances={(ARC_TOKEN, address, MAINNET_XRESERVE): 0}) + b = make_bridge(evm={'arc': Ethereum(w3=w3, private_key=KEY)}) + q = b.quote(route=IN, amount='2', recipient=ALEO, sender=address, mint_mode=mode, secret_nonce='7scalar') + assert q.plan.route_id == IN.id and q.plan.mint_mode == mode + assert q.balance_atomic == 3_000_000 + + +def test_missing_destination_domain_refuses_arc_burn(): + from aleo_bridge.registry import Registry + b = make_bridge() + r = b.registry + b.registry = Registry(r.version, [replace(c, protocol_domains={}) if c.id == 'arc' else c for c in r.chains()], + r.assets(), r.routes(include_unavailable=True)) + with pytest.raises(ConfigurationError, match='domain'): + b.xreserve.build_burn_inputs(OUT, mode='public', amount_atomic=2_000_000, + recipient='0x'+'11'*20, record=None, merkle_proof=None) + + +def test_execute_uses_one_live_fee_for_validation_and_delivery(monkeypatch): + from aleo_bridge import Receipt, Status + from aleo_bridge import lifecycle + recipient = '0x' + '11' * 20 + b = make_bridge(evm={'arc': Ethereum(w3=fake_web3(chain_id=5042, + token_balances={(ARC_TOKEN, recipient): 100}))}) + session = FeeSession() + b.xreserve.circle_session = session + plan = b.quote(route=OUT, amount='2', recipient=recipient).plan + session.calls.clear() + captured = {} + def prove(bridge, plan, call, *, extra_state, **kwargs): + captured.update(extra_state) + return Receipt('test-burn', 'xreserve', Status.SOURCE_CONFIRMING, + source_tx_id='test-burn', protocol_state={'routeId': OUT.id, **extra_state}) + monkeypatch.setattr(lifecycle, '_run_aleo_leg', prove) + b.execute(plan, mode='public') + assert len(session.calls) == 1 + assert captured['expectedDestinationIncreaseAtomic'] == '1983600' diff --git a/bridge-sdk/tests/test_cctp_agent.py b/bridge-sdk/tests/test_cctp_agent.py new file mode 100644 index 00000000..923a25f0 --- /dev/null +++ b/bridge-sdk/tests/test_cctp_agent.py @@ -0,0 +1,45 @@ +from aleo_bridge.agent import bridge_tools, dispatch_tool +from tests.fakes.fake_cctp import Harness, SENDER, RECIPIENT + + +def test_cctp_schemas_and_quote_dispatch(): + tools = {t['name']:t['input_schema'] for t in bridge_tools(include_writes=True)} + assert 'cctp' in tools['bridge_quote']['properties'] + assert 'manual_mint' in tools['bridge_complete']['properties'] + assert 'approval_replacement' in tools['bridge_get_progress']['properties'] + h = Harness() + result = dispatch_tool(h.bridge, 'bridge_quote', {'source_chain':'ethereum','source_asset':'usdc', + 'destination_chain':'arc','amount':'5','sender':SENDER,'recipient':RECIPIENT, + 'cctp':{'speed':'fast','forwarding':True,'max_fee':'0.1'}}) + assert result['kind'] == 'evm-cctp' + assert result['plan']['cctp']['max_fee'] == '0.1' + assert not h.source.sent + + +def test_manual_mint_confirm_gate_needs_no_aleo_secret(): + h = Harness(forwarding=False) + h.destination.used = False + h.circle.forward_hash = None + h.execute() + args = {'checkpoint':h.saved[-1].to_dict()} + preview = dispatch_tool(h.bridge,'bridge_complete',args) + assert 'error' not in preview + assert not h.destination.sent + result = dispatch_tool(h.bridge,'bridge_complete',{**args,'confirm':True}) + assert 'error' not in result + assert len(h.destination.sent) == 1 + assert 'attestation' not in result['progress']['receipt']['protocol_state'] + assert 'message' not in result['progress']['receipt']['protocol_state'] + + +def test_store_failure_returns_the_broadcast_checkpoint_to_agent(): + from types import SimpleNamespace + from unittest.mock import Mock + h = Harness(forwarding=False) + h.destination.used = False + h.circle.forward_hash = None + h.execute() + h.bridge.checkpoints = SimpleNamespace(save=Mock(side_effect=[None,OSError('disk unavailable')])) + result = dispatch_tool(h.bridge,'bridge_complete',{'checkpoint':h.saved[-1].to_dict(),'confirm':True}) + assert result['checkpoint']['destination']['transactionId'] == h.destination.hash_at(1) + assert result['next'] == 'recover' diff --git a/bridge-sdk/tests/test_cctp_execute.py b/bridge-sdk/tests/test_cctp_execute.py new file mode 100644 index 00000000..1808d389 --- /dev/null +++ b/bridge-sdk/tests/test_cctp_execute.py @@ -0,0 +1,111 @@ +import pytest +from eth_abi import decode +from eth_utils import keccak + +from aleo_bridge.errors import BridgeError +from aleo_bridge.types import Status +from tests.fakes.fake_cctp import Harness, SENDER + + +@pytest.mark.parametrize('other', ['ethereum', 'base', 'arbitrum']) +@pytest.mark.parametrize('reverse', [False, True]) +def test_burn_and_exact_mint_on_all_six_routes(other, reverse): + h = Harness('arc' if reverse else other, other if reverse else 'arc') + progress = h.execute() + assert progress.next == 'done' + assert len(h.source.sent) == 1 and len(h.destination.sent) == 0 + raw = bytes.fromhex(h.source.sent[0]['data'][2:]) + assert raw[:4] == keccak(text='depositForBurnWithHook(uint256,uint32,bytes32,address,bytes32,uint256,uint32,bytes)')[:4] + args = decode(['uint256','uint32','bytes32','address','bytes32','uint256','uint32','bytes'], raw[4:]) + assert args[0] == 5_000_000 and args[1] == (h.route.metadata['destinationDomain']) + assert args[4] == bytes(32) and args[5:7] == (100_000, 1000) + assert args[7] == b'cctp-forward'.ljust(24,b'\0') + bytes(8) + assert h.saved[0].source['transactionId'] == progress.receipt.source_tx_id + assert h.saved[0].intent['cctp']['max_fee'] == '0.1' + + +def test_approval_and_burn_checkpoint_before_pending_return(): + h = Harness(allowance=0) + h.source.pending_nth.add(2) + p = h.execute() + assert p.receipt.status == Status.SOURCE_CONFIRMING + assert len(h.source.sent) == 2 + assert h.saved[0].source['approvalTransactionIds'] == [h.source.hash_at(1)] + assert h.saved[-1].source['transactionId'] == h.source.hash_at(2) + + +def test_pending_approval_does_not_burn(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + assert h.execute().receipt.status == Status.SOURCE_APPROVAL_PENDING + assert len(h.source.sent) == 1 + + +@pytest.mark.parametrize('failure', ['balance', 'gas', 'fee', 'chain']) +def test_preflight_failure_does_not_approve_or_burn(failure): + h = Harness(allowance=0) + if failure == 'balance': h.source.token_balances.clear() + if failure == 'gas': h.source.eth_balances.clear() + if failure == 'fee': h.circle.minimum = '1000' + if failure == 'chain': h.source.chain_id = 99 + with pytest.raises(BridgeError): h.execute() + assert h.source.sent == [] + + +def test_lost_burn_response_keeps_signed_hash_in_checkpoint(): + h = Harness() + h.source.send_errors[1] = 'response lost' + with pytest.raises(BridgeError) as caught: h.execute() + assert h.saved[-1].source['transactionId'] == caught.value.broadcast_id + assert h.saved[-1].intent['sender'] == SENDER + + +def test_default_fee_budget_tolerates_a_tick_after_approval(): + from dataclasses import replace + from aleo_bridge import CctpOptions + h = Harness(allowance=0) + h.plan = replace(h.plan, cctp=CctpOptions('fast', True)) + h.source.pending_nth.add(2) + original = h.circle.json + reads = [] + def fee_tick(): + if '/fees/' in h.circle.urls[-1]: + reads.append(1) + if len(reads) == 3: + h.circle.forward += 1 + return original() + h.circle.json = fee_tick + p = h.execute() + assert p.receipt.status == Status.SOURCE_CONFIRMING + assert len(h.source.sent) == 2 + assert p.plan.cctp.max_fee == '0.001815' + assert h.saved[-1].intent['cctp']['max_fee'] == '0.001815' + + +def test_fee_above_approved_cap_returns_resumable_progress_without_reapproval(): + from dataclasses import replace + from aleo_bridge import CctpOptions + h = Harness(allowance=0) + h.plan = replace(h.plan, cctp=CctpOptions('fast', True, '0.00165')) + original = h.circle.json + def fee_tick(): + if len(h.source.sent) == 1: + h.circle.forward = 1001 + return original() + h.circle.json = fee_tick + p = h.execute() + assert p.next == 'resume' + assert p.receipt.source_tx_id is None and len(h.source.sent) == 1 + assert 'max_fee' in p.receipt.protocol_state['sourceError'] + from web3 import Web3 + h.source.allowances[tuple(Web3.to_checksum_address(v) for v in (h.source_token, SENDER, h.messenger))] = 5_000_000 + still_blocked = h.bridge.resume(p, timeout_seconds=0) + assert still_blocked.next == 'resume' and len(h.source.sent) == 1 + assert still_blocked.plan.cctp.max_fee == '0.00165' + h.circle.json = original + h.circle.forward = 1000 + h.source.pending_nth.add(2) + result = h.bridge.resume(still_blocked, timeout_seconds=0) + assert result.receipt.status == Status.SOURCE_CONFIRMING + assert len(h.source.sent) == 2 + assert h.saved[0].intent['cctp']['max_fee'] == '0.00165' diff --git a/bridge-sdk/tests/test_cctp_message.py b/bridge-sdk/tests/test_cctp_message.py new file mode 100644 index 00000000..cdd04ada --- /dev/null +++ b/bridge-sdk/tests/test_cctp_message.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path + +import pytest + +from aleo_bridge.errors import BridgeError + +FIXTURE = json.loads((Path(__file__).parent / 'fixtures/cctp-v2.json').read_text()) +SOURCE = bytes.fromhex(FIXTURE['source'][2:]) +ATTESTED = bytes.fromhex(FIXTURE['attested'][2:]) +KW = dict(source_domain=0, destination_domain=26, messenger='0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d', + source_token='0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + sender='0x0000000000000000000000000000000000000011', + recipient='0x0000000000000000000000000000000000000022', amount_atomic=5_000_000, + max_fee_atomic=100_000, finality=1000, forwarding=True) + + +def test_upstream_vectors_keep_only_circle_mutable_fields(): + from aleo_bridge._cctp_message import validate_message, immutable_message + a = validate_message(ATTESTED, **KW) + assert a.amount == 5_000_000 and a.fee == 10000 and int.from_bytes(a.nonce, 'big') == 123 + assert immutable_message(SOURCE) == immutable_message(ATTESTED) + + +@pytest.mark.parametrize('offset', [0, 4, 8, 44, 76, 108, 140, 148, 152, 184, 216, 248, 280, 376, 407]) +def test_each_immutable_field_is_bound_to_intent(offset): + from aleo_bridge._cctp_message import validate_message + changed = bytearray(SOURCE) + changed[offset] ^= 2 + with pytest.raises(BridgeError): + validate_message(bytes(changed), **KW) + + +@pytest.mark.parametrize('size', [0, 147, 375, 377, 407, 409]) +def test_truncated_or_unrecognized_hook_refused(size): + from aleo_bridge._cctp_message import validate_message + raw = (SOURCE + b'\0')[:size] + with pytest.raises(BridgeError): + validate_message(raw, **KW) + + +def test_v1_hook_accepted_for_recovery_but_new_frame_is_v0(): + from aleo_bridge._cctp_message import validate_message, FORWARD_HOOK + assert FORWARD_HOOK == b'cctp-forward'.ljust(24, b'\0') + bytes(8) + changed = bytearray(SOURCE) + changed[403] = 1 + validate_message(bytes(changed), **KW) diff --git a/bridge-sdk/tests/test_cctp_quote.py b/bridge-sdk/tests/test_cctp_quote.py new file mode 100644 index 00000000..aaeb4ed0 --- /dev/null +++ b/bridge-sdk/tests/test_cctp_quote.py @@ -0,0 +1,107 @@ +"""CCTP estimates preserve the approved ceiling and exact six-decimal accounting.""" +from dataclasses import replace + +import pytest + +from aleo_bridge.errors import BridgeError +from aleo_bridge.lifecycle import prepare +from aleo_bridge.registry import DEFAULT_REGISTRY +from tests.fakes.fake_web3 import make_bridge + +ROUTES = [f'cctp:{a}/usdc->{b}/usdc' for other in ('ethereum', 'base', 'arbitrum') + for a, b in ((other, 'arc'), ('arc', other))] +SENDER, RECIPIENT = '0x'+'11'*20, '0x'+'22'*20 + + +class CircleSession: + def __init__(self, minimum='1.3', forward=1000): + self.minimum, self.forward = minimum, forward + self.urls = [] + self.status_code = 200 + self.body = None + + def get(self, url, **kwargs): + self.urls.append(url) + return self + + def json(self): + if self.body is not None: + return self.body + return [{'finalityThreshold': 1000, 'minimumFee': self.minimum, 'forwardFee': {'medium': self.forward}}, + {'finalityThreshold': 2000, 'minimumFee': '0', 'forwardFee': {'med': self.forward}}] + + +def bridge(): + b = make_bridge() + b.cctp.circle_session = CircleSession() + return b + + +@pytest.mark.parametrize('route', ROUTES) +def test_fast_quote_deducts_full_forwarding_ceiling(route): + b = bridge() + q = b.quote(route=route, amount='5', sender=SENDER, recipient=RECIPIENT, + cctp={'speed': 'fast', 'max_fee': '0.1'}) + assert q.kind == 'evm-cctp' and q.protocol_fee_atomic == 650 + assert q.forwarding_fee_atomic == 1000 and q.max_fee_atomic == 100_000 + assert q.amount_out == '4.9' and q.amount_out_atomic == 4_900_000 + assert q.plan.cctp.max_fee == '0.1' and q.min_finality_threshold == 1000 + + +def test_standard_defaults_and_plan_serialization(): + from aleo_bridge import Plan + b = bridge() + q = b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT) + assert q.plan.cctp.speed == 'standard' and q.plan.cctp.forwarding is True + assert q.plan.cctp.max_fee == '0.0011' and q.amount_out == '4.9989' + assert Plan.from_dict(q.plan.to_dict()) == q.plan + assert b.cctp.circle_session.urls[-1].endswith('/fees/0/26?forward=true') + + +def test_manual_quote_deducts_estimate_and_requote_never_raises_cap(): + b = bridge() + q = b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT, + cctp={'speed': 'fast', 'forwarding': False, 'max_fee': '0.1'}) + assert q.amount_out == '4.99935' and q.forwarding_fee_atomic == 0 + assert q.plan.steps[-1].executor == 'evm-wallet' + b.cctp.circle_session.minimum = '1000' + with pytest.raises(BridgeError, match='exceed'): + b.cctp.quote(q.plan) + + +def test_fractional_basis_points_round_up(): + b = bridge() + b.cctp.circle_session.minimum = '0.0001' + q = b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT, + cctp={'speed': 'fast', 'forwarding': False}) + assert q.protocol_fee_atomic == 1 and q.amount_out == '4.999999' + + +@pytest.mark.parametrize('options', [{'speed': 'warp'}, {'forwarding': 1}, {'max_fee': '-1'}, + {'max_fee': True}, {'max_fee': '5'}, {'unknown': 1}]) +def test_invalid_options_cannot_prepare_or_quote(options): + b = bridge() + with pytest.raises(BridgeError): + b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT, cctp=options) + + +@pytest.mark.parametrize('minimum,forward', [(True, 1000), ('-1', 1000), ('nan', 1000), ('1e2', 1000), + ('1', True), ('1', -1), ('1', '1.5')]) +def test_malformed_provider_fees_refused(minimum, forward): + b = bridge() + b.cctp.circle_session = CircleSession(minimum, forward) + with pytest.raises(BridgeError): + b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT, cctp={'speed': 'fast'}) + + +def test_cctp_options_refused_on_other_protocols(): + with pytest.raises(BridgeError, match='CCTP'): + prepare(DEFAULT_REGISTRY, route='xreserve:ethereum/usdc->aleo/usdcx', amount='2', + recipient='aleo1'+'a'*58, cctp={}) + + +def test_changed_plan_amount_and_mislabeled_asset_refused(): + b = bridge() + q = b.quote(route=ROUTES[0], amount='5', recipient=RECIPIENT) + with pytest.raises(BridgeError): + b.cctp.quote(replace(q.plan, amount_atomic=6_000_000)) diff --git a/bridge-sdk/tests/test_cctp_recovery.py b/bridge-sdk/tests/test_cctp_recovery.py new file mode 100644 index 00000000..fbf08d63 --- /dev/null +++ b/bridge-sdk/tests/test_cctp_recovery.py @@ -0,0 +1,403 @@ +from dataclasses import replace + +import pytest +from eth_abi import decode +from eth_utils import keccak + +from aleo_bridge import Status, create_checkpoint +from aleo_bridge.errors import BridgeError +from tests.fakes.fake_cctp import Harness, SENDER, DEST_HASH + + +def test_pending_burn_checkpoint_roundtrip_never_resends(): + h = Harness() + h.source.pending_nth.add(1) + p = h.execute() + recovered = h.bridge.recover(h.saved[-1].to_json()) + assert recovered.plan.cctp == p.plan.cctp + assert recovered.receipt.status == Status.SOURCE_CONFIRMING + assert len(h.source.sent) == 1 + h.source.pending.clear() + assert h.bridge.recover(h.saved[-1]).next == 'done' + assert len(h.source.sent) == 1 + + +def test_approval_recovery_and_resume_burn_once(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[-1] + assert h.bridge.recover(cp).receipt.status == Status.SOURCE_APPROVAL_PENDING + h.source.pending.clear() + from web3 import Web3 + h.source.allowances[tuple(Web3.to_checksum_address(v) for v in (h.source_token, SENDER, h.messenger))] = 5_000_000 + recovered = h.bridge.recover(cp) + assert recovered.next == 'resume' + result = h.bridge.resume(recovered, timeout_seconds=0, on_checkpoint=h.saved.append) + assert result.next == 'done' + assert len(h.source.sent) == 2 + assert h.saved[-1].intent['cctp']['max_fee'] == '0.1' + + +def test_manual_completion_and_second_call_do_not_resend(): + h = Harness(forwarding=False) + h.destination.used = False + h.circle.forward_hash = None + p = h.execute() + assert p.next == 'complete' + minted = h.bridge.complete(p, on_checkpoint=h.saved.append) + assert minted.receipt.status == Status.DESTINATION_CONFIRMING + data = bytes.fromhex(h.destination.sent[0]['data'][2:]) + assert data[:4] == keccak(text='receiveMessage(bytes,bytes)')[:4] + assert decode(['bytes','bytes'],data[4:]) == (h.attested, bytes.fromhex('abcd')) + assert h.saved[-1].destination['transactionId'] == minted.receipt.destination_tx_id + assert 'message' not in h.saved[-1].to_json() + h.bridge.complete(minted) + assert len(h.destination.sent) == 1 + h.destination.used = True + assert h.bridge.recover(h.saved[-1]).next == 'done' + + +def test_forwarding_fallback_requires_explicit_manual_mint(): + h = Harness() + h.circle.forward_hash = None + h.destination.used = False + p = h.execute() + with pytest.raises(BridgeError): h.bridge.complete(p) + assert not h.destination.sent + assert h.bridge.complete(p, manual_mint=True).receipt.status == Status.DESTINATION_CONFIRMING + + +def test_used_nonce_cannot_be_manually_minted_without_evidence(): + h = Harness() + h.circle.forward_hash = None + with pytest.raises(BridgeError): h.bridge.complete(h.execute(), manual_mint=True) + assert not h.destination.sent + + +def test_lost_destination_response_keeps_checkpoint(): + h = Harness(forwarding=False) + h.circle.forward_hash = None + h.destination.used = False + h.destination.send_errors[1] = 'response lost' + with pytest.raises(BridgeError) as caught: + h.bridge.complete(h.execute(), on_checkpoint=h.saved.append) + assert h.saved[-1].destination['transactionId'] == caught.value.broadcast_id + + +@pytest.mark.parametrize('field', ['sender','amount','spender','value']) +def test_approval_recovery_rejects_mismatched_transaction(field): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[-1] + h.source.pending.clear() + tx = h.source.sent[0] + if field == 'sender': tx['from'] = '0x'+'33'*20 + elif field == 'value': tx['value'] = 1 + else: + from eth_abi import encode + tx['data'] = '0x'+(keccak(text='approve(address,uint256)')[:4] + encode(['address','uint256'], + ['0x'+'33'*20 if field == 'spender' else h.messenger, 1 if field == 'amount' else 5_000_000])).hex() + with pytest.raises(BridgeError): h.bridge.recover(cp) + assert len(h.source.sent) == 1 + + +def replacement_case(): + from eth_abi import encode + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[-1] + original = cp.source['approvalTransactionIds'][0] + replacement = '0x'+'44'*32 + h.source.tx_not_found.add(original) + h.source.add_transaction(replacement, sender=SENDER, to=h.source_token) + h.source.transactions[replacement]['input'] = '0x'+(keccak(text='approve(address,uint256)')[:4]+ + encode(['address','uint256'], [h.messenger,5_000_000])).hex() + h.source.add_receipt(replacement) + return h, cp, {'original_transaction_id': original, 'replacement_transaction_id': replacement} + + +def test_explicit_approval_replacement_preserves_history_and_fee_cap(): + h, cp, selection = replacement_case() + recovered = h.bridge.recover(cp, approval_replacement=selection) + assert recovered.next == 'resume' + saved = create_checkpoint(recovered.plan, recovered.receipt, h.bridge.registry) + assert saved.source['approvalTransactionIds'] == [selection['replacement_transaction_id']] + assert saved.source['replacedApprovalTransactionIds'] == [selection['original_transaction_id']] + assert saved.intent['cctp']['max_fee'] == '0.1' + assert len(h.source.sent) == 1 + + +@pytest.mark.parametrize('failure',['visible','same','not_saved','pending','bad_amount','after_burn']) +def test_replacement_requires_absent_original_and_bound_confirmed_approval(failure): + h, cp, selection = replacement_case() + if failure == 'visible': h.source.tx_not_found.clear() + if failure == 'same': selection['replacement_transaction_id'] = selection['original_transaction_id'] + if failure == 'not_saved': selection['original_transaction_id'] = DEST_HASH + if failure == 'pending': h.source.pending.add(selection['replacement_transaction_id']) + if failure == 'bad_amount': h.source.transactions[selection['replacement_transaction_id']]['input'] = '0x' + if failure == 'after_burn': cp = replace(cp, source={**cp.source,'transactionId':DEST_HASH}) + with pytest.raises(BridgeError): h.bridge.recover(cp, approval_replacement=selection) + assert len(h.source.sent) == 1 + + +@pytest.mark.parametrize('failure',['gas','signer']) +def test_manual_destination_requires_wallet_and_gas(failure): + h = Harness(forwarding=False) + h.destination.used = False + h.circle.forward_hash = None + p = h.execute() + if failure == 'gas': h.destination.eth_balances.clear() + else: + from aleo_bridge import Ethereum + from web3 import Web3 + h.bridge.evm('arc').conn = Ethereum(w3=Web3(h.destination)) + with pytest.raises(BridgeError): h.bridge.complete(p) + assert not h.destination.sent + + +def test_offline_checkpoint_reconstruction_excludes_attestation(): + from aleo_bridge.lifecycle import progress_from_checkpoint + h = Harness(forwarding=False) + h.circle.forward_hash = None + h.destination.used = False + p = h.execute() + cp = create_checkpoint(p.plan,p.receipt,h.bridge.registry) + h.source.methods.clear(); h.destination.methods.clear(); h.circle.urls.clear() + offline = progress_from_checkpoint(h.bridge.registry,cp) + assert offline.plan.cctp == p.plan.cctp + assert 'attestation' not in cp.to_json() + assert h.source.methods == h.destination.methods == h.circle.urls == [] + + +def test_callback_failure_preserves_burn_in_store_and_error(tmp_path): + from aleo_bridge import FileCheckpointStore + store = FileCheckpointStore(tmp_path) + h = Harness(allowance=0,checkpoints=store) + def callback(cp): + if cp.source.get('transactionId'): + raise OSError('callback disk unavailable') + with pytest.raises(BridgeError) as caught: + h.bridge.execute(h.plan,on_checkpoint=callback,timeout_seconds=0) + assert caught.value.broadcast_id == h.source.hash_at(2) + assert caught.value.checkpoint.source['transactionId'] == h.source.hash_at(2) + assert any(cp.source.get('transactionId') == h.source.hash_at(2) for cp in store.list()) + assert h.bridge.recover(caught.value.checkpoint).next == 'done' + assert len(h.source.sent) == 2 + + +def test_stale_approval_recovers_existing_burn_before_resuming(): + h = Harness(allowance=0) + h.execute() + approval = h.saved[0] + burn = h.source.hash_at(2) + h.source.history_logs = h.source_logs(burn) + h.source.history_logs[0]['blockNumber'] = hex(101) + # Receipt and event providers now share a consistent mined head. + h.source.block_number = 101 + recovered = h.bridge.recover(approval) + assert recovered.next == 'done' + assert recovered.receipt.source_tx_id == burn + assert len(h.source.sent) == 2 + + +def test_stale_approval_with_unresolved_later_nonce_cannot_resume(): + h = Harness(allowance=0) + h.source.pending_nth.add(2) + h.execute() + h.source.nonce_latest = 1 # Only the approval is mined; the later burn remains pending. + recovered = h.bridge.recover(h.saved[0]) + assert recovered.next == 'wait' + assert recovered.receipt.status == Status.SOURCE_APPROVAL_PENDING + assert len(h.source.sent) == 2 + + +def test_confirmed_unrelated_activity_after_approval_does_not_block_resume(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + h.source.pending.clear() + h.bridge.evm('ethereum').conn.send_transaction({'to': '0x0000000000000000000000000000000000000022', 'value': 0}) + h.source.nonce_latest = 2 + result = h.bridge.recover(h.saved[0]) + assert result.next == 'resume' + assert result.receipt.source_tx_id is None + assert len(h.source.sent) == 2 # Recovery itself cannot send a burn. + + +def test_activity_after_scanned_head_still_blocks_approval_resume(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + h.source.pending.clear() + h.source.nonce_latest = 1 + h.source.nonce_pending = 2 + requested_tags = [] + eth = h.bridge.evm('ethereum').conn.w3.eth + original = eth.get_transaction_count + + def track(address, block_identifier='latest'): + requested_tags.append(block_identifier) + return original(address, block_identifier) + + eth.get_transaction_count = track + assert h.bridge.recover(h.saved[0]).next == 'wait' + assert h.source.block_number in requested_tags + + +def test_approval_recovery_rejects_a_changed_scanned_head(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + h.source.pending.clear() + h.source.nonce_latest = h.source.nonce_pending = 2 + eth = h.bridge.evm('ethereum').conn.w3.eth + original = eth.get_block + calls = [] + + def changed_head(*args, **kwargs): + block = dict(original(*args, **kwargs)) + calls.append(1) + block['hash'] = bytes([len(calls)]) * 32 + return block + + eth.get_block = changed_head + with pytest.raises(BridgeError, match='head block changed'): + h.bridge.recover(h.saved[0]) + assert len(h.source.sent) == 1 + + +def test_approval_does_not_adopt_identical_burn_before_its_nonce(): + h = Harness(allowance=0) + h.execute() + cp = h.saved[0] + approval,burn = h.source.sent + # Model an older identical burn and a subsequent approval in the same block. + approval['nonce'],burn['nonce'] = 2,1 + h.source.nonce_pending = 3 + h.source.nonce_latest = 3 + h.source.history_logs = h.source_logs(burn['hash']) + h.source.history_logs[0]['blockNumber'] = hex(h.source.block_number) + result = h.bridge.recover(cp) + assert result.next == 'resume' + assert result.receipt.source_tx_id is None + + +def test_old_approval_recovery_batches_history_before_allowing_resume(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[0] + h.source.pending.clear() + # Pin the mined approval so advancing the provider head does not move it. + h.source.receipts[h.source.hash_at(1)] = h.source._receipt(h.source.hash_at(1)) + h.source.block_number = 25100 + result = h.bridge.recover(cp) + assert result.next == 'wait' + assert len(h.source.log_filters) <= 10 + for _ in range(3): + before = len(h.source.log_filters) + result = h.bridge.recover(cp) + assert len(h.source.log_filters) - before <= 10 + if result.next == 'resume': + break + assert result.next == 'resume' + assert len(h.source.sent) == 1 + spans = [(int(f['fromBlock'],16),int(f['toBlock'],16)) for f in h.source.log_filters] + assert spans[0][0] == 16 and spans[-1][1] == 25100 + assert all(spans[i+1][0] == spans[i][1]+1 for i in range(len(spans)-1)) + + +def test_old_approval_finds_later_burn_without_repeating_it(): + h = Harness(allowance=0) + h.execute() + cp = h.saved[0] + h.source.receipts[h.source.hash_at(1)] = h.source._receipt(h.source.hash_at(1)) + burn = h.source.hash_at(2) + h.source.history_logs = h.source_logs(burn) + h.source.history_logs[0]['blockNumber'] = hex(20100) + h.source.block_number = 25100 + result = h.bridge.recover(cp) + assert result.next == 'wait' + for _ in range(3): + result = h.bridge.recover(cp) + if result.next == 'done': + break + assert result.next == 'done' and result.receipt.source_tx_id == burn + assert len(h.source.sent) == 2 + + +def test_partial_source_scan_discards_progress_after_reorg(): + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[0] + h.source.pending.clear() + h.source.receipts[h.source.hash_at(1)] = h.source._receipt(h.source.hash_at(1)) + h.source.block_number = 25100 + assert h.bridge.recover(cp).next == 'wait' + eth = h.bridge.evm('ethereum').conn.w3.eth + original = eth.get_block + def reorg(*args, **kwargs): + block = dict(original(*args, **kwargs)) + block['hash'] = b'\xcd' * 32 + return block + eth.get_block = reorg + with pytest.raises(BridgeError, match='head block changed'): + h.bridge.recover(cp) + before = len(h.source.log_filters) + assert h.bridge.recover(cp).next == 'wait' + assert int(h.source.log_filters[before]['fromBlock'], 16) == 16 + assert len(h.source.sent) == 1 + + +def test_new_client_restarts_partial_source_scan_safely(): + from aleo_bridge.cctp import CctpModule + h = Harness(allowance=0) + h.source.pending_nth.add(1) + h.execute() + cp = h.saved[0] + h.source.pending.clear() + h.source.receipts[h.source.hash_at(1)] = h.source._receipt(h.source.hash_at(1)) + h.source.block_number = 25100 + assert h.bridge.recover(cp).next == 'wait' + # Exercise a new protocol module directly: no in-memory scan can be trusted + # merely because a caller claims to have scanned earlier blocks. + module = CctpModule(h.bridge) + before = len(h.source.log_filters) + state = module.recover(h.plan, cp) + assert state.status == Status.SOURCE_APPROVAL_PENDING + assert int(h.source.log_filters[before]['fromBlock'], 16) == 16 + assert len(h.source.sent) == 1 + + +def test_extending_source_scan_rechecks_previous_anchor_before_resuming(): + h = Harness(allowance=0) + h.execute() + cp = h.saved[0] + h.source.receipts[h.source.hash_at(1)] = h.source._receipt(h.source.hash_at(1)) + h.source.block_number = 100 + assert h.bridge.recover(cp).next == 'resume' + h.source.block_number = 101 + eth = h.bridge.evm('ethereum').conn.w3.eth + original = eth.get_block + switched = [False] + def reorg_on_extension(number, *args, **kwargs): + block = dict(original(number, *args, **kwargs)) + if number > 100: + switched[0] = True + # The replacement chain contains a burn inside the cached prefix. + h.source.history_logs = h.source_logs(h.source.hash_at(2)) + h.source.history_logs[0]['blockNumber'] = hex(90) + if switched[0]: + block['hash'] = b'\xcd' * 32 + return block + eth.get_block = reorg_on_extension + with pytest.raises(BridgeError, match='head block changed'): + h.bridge.recover(cp) + recovered = h.bridge.recover(cp) + assert recovered.next == 'done' + assert recovered.receipt.source_tx_id == h.source.hash_at(2) + assert len(h.source.sent) == 2 diff --git a/bridge-sdk/tests/test_cctp_status.py b/bridge-sdk/tests/test_cctp_status.py new file mode 100644 index 00000000..50b4768d --- /dev/null +++ b/bridge-sdk/tests/test_cctp_status.py @@ -0,0 +1,211 @@ +import pytest + +from aleo_bridge import Receipt, Status +from aleo_bridge.errors import BridgeError +from tests.fakes.fake_cctp import Harness, DEST_HASH + + +@pytest.mark.parametrize('failure', ['mint_amount', 'missing_mint', 'missing_message', 'nonce', 'source_message', 'attested_fee', 'recipient']) +def test_unrelated_or_invalid_evidence_never_completes(failure): + h = Harness() + if failure == 'mint_amount': h.destination.receipts[DEST_HASH]['logs'] = h.destination_logs(amount=1) + if failure == 'missing_mint': h.destination.receipts[DEST_HASH]['logs'].pop() + if failure == 'missing_message': h.destination.receipts[DEST_HASH]['logs'].pop(0) + if failure == 'nonce': h.destination.used = False + if failure == 'source_message': h.burned = h.burned[:216]+(1).to_bytes(32,'big')+h.burned[248:] + if failure == 'attested_fee': h.attested = h.attested[:312]+(100001).to_bytes(32,'big')+h.attested[344:] + if failure == 'recipient': h.attested = h.attested[:184]+bytes(32)+h.attested[216:] + if failure == 'recipient': + # Veil filters unrelated attestation messages, including those from a batch. + assert h.execute().receipt.status == Status.ATTESTATION_PENDING + elif failure == 'nonce': + assert h.execute().next == 'wait' + else: + with pytest.raises(BridgeError): h.execute() + + +def test_used_nonce_without_destination_proof_stays_pending(): + h = Harness() + h.circle.forward_hash = None + p = h.execute() + assert p.receipt.status == Status.DELIVERY_PENDING + assert h.destination.log_filters + assert all(f['topics'][2] == '0x'+(123).to_bytes(32,'big').hex() for f in h.destination.log_filters) + + +def test_missing_forward_hash_recovers_from_matching_event(): + h = Harness() + h.circle.forward_hash = None + h.destination.history_logs = h.destination_logs() + assert h.execute().next == 'done' + + +@pytest.mark.parametrize('head', [0, 999, 1000, 15000]) +def test_scan_is_bounded_and_covers_contiguous_ranges(head): + h = Harness() + h.circle.forward_hash = None + h.destination.block_number = head + assert h.execute().next == 'wait' + spans = [(int(f['fromBlock'],16),int(f['toBlock'],16)) for f in h.destination.log_filters] + assert spans[0][1] == head and spans[-1][0] == max(0,head-9999) + assert all(end-start < 1000 for start,end in spans) + assert all(spans[i+1][1] == spans[i][0]-1 for i in range(len(spans)-1)) + + +def test_terminal_receipt_is_not_polled(): + h = Harness() + p = h.execute() + h.source.methods.clear(); h.destination.methods.clear(); h.circle.urls.clear() + assert h.bridge.get_status(p.plan,p.receipt) is p.receipt + assert h.source.methods == h.destination.methods == h.circle.urls == [] + + +def test_reverted_burn_recovery_fails_without_mint(): + h = Harness() + h.source.pending_nth.add(1) + p = h.execute() + h.source.pending.clear() + h.source.reverted.add(p.receipt.source_tx_id) + assert h.bridge.get_status(p.plan, p.receipt).status == Status.FAILED + assert h.destination.sent == [] + + +@pytest.mark.parametrize('variant', ['pending', 'duplicate', 'wrong_hash', 'rpc_error']) +def test_attestation_response_and_transport_failures(variant): + h = Harness() + if variant == 'pending': + h.circle.attestation_status = 'pending_confirmations' + assert h.execute().receipt.status == Status.ATTESTATION_PENDING + return + old_json = h.circle.json + def response(): + result = old_json() + if '/fees/' in h.circle.urls[-1]: return result + if variant == 'rpc_error': raise ConnectionError('offline') + if variant == 'duplicate': result['messages'] *= 2 + else: result['sourceTxHash'] = DEST_HASH + return result + h.circle.json = response + with pytest.raises((BridgeError, ConnectionError)): h.execute() + assert len(h.source.sent) == 1 + + +def test_reverted_forwarding_permits_manual_action(): + h = Harness() + h.destination.used = False + h.destination.receipts[DEST_HASH]['status'] = '0x0' + p = h.execute() + assert p.next == 'complete' + assert p.receipt.destination_tx_id is None + + +@pytest.mark.parametrize('failure', ['connection', 'timeout']) +def test_wait_retries_circle_transport_failure_without_resubmission(failure): + import requests + h = Harness() + h.circle.attestation_status = 'pending' + progress = h.execute() + h.circle.attestation_status = 'complete' + original = h.circle.get + calls = [] + + def get(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + cls = requests.ConnectionError if failure == 'connection' else requests.Timeout + raise cls('temporary transport failure') + return original(*args, **kwargs) + + h.circle.get = get + result = h.bridge.wait(progress, poll_seconds=0.001, timeout_seconds=2) + assert result.next == 'done' + assert len(calls) == 2 and len(h.source.sent) == 1 + + +def test_wait_does_not_retry_malformed_circle_json(): + import requests + h = Harness() + h.circle.attestation_status = 'pending' + progress = h.execute() + calls = [] + + def invalid_json(): + calls.append(1) + raise requests.exceptions.JSONDecodeError('invalid JSON', 'not json', 0) + + h.circle.json = invalid_json + with pytest.raises(BridgeError, match='Circle CCTP request failed'): + h.bridge.wait(progress, poll_seconds=0.001, timeout_seconds=2) + assert len(calls) == 1 and len(h.source.sent) == 1 + + +@pytest.mark.parametrize('failure', [429, 500, 503, 'chunked']) +def test_wait_retries_circle_service_failures(failure): + import requests + h = Harness() + h.circle.attestation_status = 'pending' + progress = h.execute() + h.circle.attestation_status = 'complete' + original = h.circle.get + calls = [] + def get(*args, **kwargs): + calls.append(1) + h.circle.status_code = 200 + if len(calls) == 1: + if failure == 'chunked': + raise requests.exceptions.ChunkedEncodingError('truncated') + h.circle.status_code = failure + return original(*args, **kwargs) + h.circle.get = get + result = h.bridge.wait(progress, poll_seconds=0.001, timeout_seconds=2) + assert result.next == 'done' + assert len(calls) == 2 and len(h.source.sent) == 1 + + +def test_old_delivery_is_found_across_bounded_polls(): + h = Harness() + h.circle.forward_hash = None + h.destination.block_number = 25000 + h.destination.history_logs = h.destination_logs() + p = h.execute() + assert p.next == 'wait' + assert len(h.destination.log_filters) <= 10 + for _ in range(3): + before = len(h.destination.log_filters) + receipt = h.bridge.get_status(p.plan, p.receipt) + assert len(h.destination.log_filters) - before <= 10 + if receipt.status == Status.COMPLETED: + break + assert receipt.status == Status.COMPLETED + assert receipt.destination_tx_id == DEST_HASH + assert len(h.source.sent) == 1 and not h.destination.sent + + +def test_partial_destination_scan_discards_progress_after_reorg(): + h = Harness() + h.circle.forward_hash = None + h.destination.block_number = 25000 + p = h.execute() + eth = h.bridge.evm('arc').conn.w3.eth + original = eth.get_block + def reorg(*args, **kwargs): + block = dict(original(*args, **kwargs)) + block['hash'] = b'\xcd' * 32 + return block + eth.get_block = reorg + with pytest.raises(BridgeError, match='head block changed'): + h.bridge.get_status(p.plan, p.receipt) + before = len(h.destination.log_filters) + assert h.bridge.get_status(p.plan, p.receipt).status == Status.DELIVERY_PENDING + assert int(h.destination.log_filters[before]['toBlock'], 16) == 25000 + assert not h.destination.sent + + +@pytest.mark.parametrize('code', [400, 401, 403]) +def test_circle_client_errors_are_not_retried(code): + from aleo_bridge.lifecycle import _is_transient_error + h = Harness() + h.circle.status_code = code + with pytest.raises(BridgeError) as caught: + h.bridge.cctp._json('https://unused.invalid') + assert not _is_transient_error(caught.value) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 6f94aee1..345f9342 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -239,9 +239,9 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None 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 len(routes) == 30 and routes[0] == "xreserve:ethereum/usdc->aleo/usdcx" assert cli.main(["assets"]) == 0 - assert len(json.loads(capsys.readouterr().out)) == 19 + assert len(json.loads(capsys.readouterr().out)) == 22 assert cli.main(["bogus"]) == 2 @@ -261,7 +261,7 @@ def test_routes_filters_the_registry_for_this_environment(fake_aleo): assert [r.id for r in bridge.routes(source_chain="solana", destination_chain="aleo")] == \ ["hyperlane:solana/sol->aleo/sol", "hyperlane:solana/aleo->aleo/aleo"] assert [r.id for r in bridge.routes(source_chain="ethereum", source_asset="usdc")] == \ - ["xreserve:ethereum/usdc->aleo/usdcx"] + ["xreserve:ethereum/usdc->aleo/usdcx", "cctp:ethereum/usdc->arc/usdc"] assert bridge.routes() == REG.routes(environment="mainnet") assert bridge.routes(bridge_protocol="xreserve", include_unavailable=True) == \ REG.routes(bridge_protocol="xreserve", include_unavailable=True, environment="mainnet") diff --git a/bridge-sdk/tests/test_evm_connections.py b/bridge-sdk/tests/test_evm_connections.py new file mode 100644 index 00000000..1cf41424 --- /dev/null +++ b/bridge-sdk/tests/test_evm_connections.py @@ -0,0 +1,109 @@ +from unittest.mock import patch + +import pytest + +from aleo_bridge import Bridge, Ethereum +from aleo_bridge.errors import ConfigurationError, ChainMismatchError +from tests.conftest import FakeAleo, default_mappings +from tests.fakes.fake_web3 import fake_web3 + +KEY = '0x' + '11' * 32 + + +def make(**kwargs): + return Bridge(FakeAleo(mappings=default_mappings()), **kwargs) + + +def test_route_selected_connections_preserve_ethereum_alias(): + eth, arc = Ethereum(w3=fake_web3(chain_id=1)), Ethereum(w3=fake_web3(chain_id=5042)) + b = make(ethereum=eth, evm={'ARC': arc}) + assert b.eth.conn is eth and b.evm('ethereum') is b.eth + assert b.evm('arc').conn is arc and b.evm('arc').chain.id == 'arc' + + +def test_duplicate_connection_cannot_silently_select_signer(): + with pytest.raises(ConfigurationError, match='(?i)conflict'): + make(ethereum=Ethereum(w3=fake_web3()), evm={'ethereum': Ethereum(w3=fake_web3())}) + + +@pytest.mark.parametrize('chain', ['solana', 'aleo', 'sepolia']) +def test_map_rejects_family_or_environment_mismatch(chain): + with pytest.raises(ConfigurationError): + make(evm={chain: Ethereum(w3=fake_web3())}) + + +def test_arc_status_reads_arc_token_without_double_counting_native_view(): + from eth_account import Account + address = Account.from_key(KEY).address + w3 = fake_web3(chain_id=5042, eth_balances={address: 10**18}, + token_balances={('0x3600000000000000000000000000000000000000', address): 10**6}) + b = make(evm={'arc': Ethereum(w3=w3, private_key=KEY)}) + arc = next(c for c in b.status().chains if c.chain_id == 'arc') + assert arc.balances == {'arc/usdc': 10**6} + assert b.evm('arc')._native_fee(10**16).amount == '0.01' + + +def test_arc_connection_rejects_ethereum_rpc_before_status_reads(): + b = make(evm={'arc': Ethereum(w3=fake_web3(chain_id=1), private_key=KEY)}) + with pytest.raises(ChainMismatchError): + b.evm('arc').chain_status() + + +def test_arc_only_from_env_does_not_require_ethereum_rpc(monkeypatch): + monkeypatch.setenv('BRIDGE_PRIVATE_KEY', 'fake') + monkeypatch.setenv('EVM_PRIVATE_KEY', KEY) + monkeypatch.setenv('ARC_RPC_URL', 'https://arc.invalid') + for key in ('ETHEREUM_RPC_URL', 'BRIDGE_LIVE_ETHEREUM_RPC_URL', 'BASE_RPC_URL', 'ARBITRUM_RPC_URL'): + monkeypatch.delenv(key, raising=False) + with patch('aleo_bridge.client.build_aleo', return_value=FakeAleo(mappings=default_mappings())): + b = Bridge.from_env() + assert b.ethereum is None + assert b.evm('arc').conn.address is not None + + +def test_explicit_arc_override_wins_over_environment(monkeypatch): + monkeypatch.setenv('BRIDGE_PRIVATE_KEY', 'fake') + monkeypatch.setenv('ARC_RPC_URL', 'https://unused.invalid') + arc = Ethereum(w3=fake_web3(chain_id=5042)) + with patch('aleo_bridge.client.build_aleo', return_value=FakeAleo(mappings=default_mappings())): + b = Bridge.from_env(ethereum=None, solana=None, evm={'arc': arc}) + assert b.evm('arc').conn is arc + + +def test_explicit_ethereum_map_override_wins_over_environment(monkeypatch): + monkeypatch.setenv('BRIDGE_PRIVATE_KEY','fake') + monkeypatch.setenv('ETHEREUM_RPC_URL','https://unused.invalid') + monkeypatch.setenv('EVM_PRIVATE_KEY',KEY) + connection = Ethereum(w3=fake_web3()) + with patch('aleo_bridge.client.build_aleo',return_value=FakeAleo(mappings=default_mappings())): + b = Bridge.from_env(evm={'ethereum':connection},solana=None) + assert b.ethereum is connection + + +def test_profile_ethereum_map_override_wins_over_environment(monkeypatch): + from types import SimpleNamespace + monkeypatch.setenv('ETHEREUM_RPC_URL','https://unused.invalid') + monkeypatch.setenv('EVM_PRIVATE_KEY',KEY) + connection = Ethereum(w3=fake_web3()) + profile = SimpleNamespace(endpoint='https://unused.invalid',network='mainnet',private_key='fake') + with patch('aleo_bridge.client.build_aleo',return_value=FakeAleo(mappings=default_mappings())), \ + patch('aleo_bridge.client.Profile.load_or_create',return_value=profile), \ + patch('aleo_bridge.client._checkpoints_for_profile',return_value=None): + b = Bridge.from_profile(evm={'ethereum':connection}) + assert b.ethereum is connection + + +@pytest.mark.parametrize('factory', ['env', 'profile']) +@pytest.mark.parametrize('variable', ['ARC_RPC_URL', 'BASE_RPC_URL', 'ARBITRUM_RPC_URL']) +def test_testnet_ignores_automatically_loaded_mainnet_connections(factory, variable): + import os + from types import SimpleNamespace + with patch.dict(os.environ, {'BRIDGE_PRIVATE_KEY': 'fake', 'ALEO_NETWORK': 'testnet', + variable: 'https://unused.invalid'}, clear=True), \ + patch('aleo_bridge.client.build_aleo', return_value=FakeAleo(network_name='testnet')), \ + patch('aleo_bridge.client.Profile.load_or_create', return_value=SimpleNamespace( + endpoint='https://unused.invalid', network='testnet', private_key='fake')), \ + patch('aleo_bridge.client._checkpoints_for_profile', return_value=None): + b = Bridge.from_env() if factory == 'env' else Bridge.from_profile() + assert b.environment == 'testnet' + assert b._evm_connections == {} diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py index 9424c14b..05181eef 100644 --- a/bridge-sdk/tests/test_live_helpers.py +++ b/bridge-sdk/tests/test_live_helpers.py @@ -114,8 +114,8 @@ def test_gates_only_read_the_environment(monkeypatch): 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", +def test_case_names_include_cctp(): + assert live_config.CASE_NAMES == ("evm-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") @@ -487,8 +487,8 @@ def test_every_mainnet_route_is_covered_by_exactly_one_case(): 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"} + assert by_case["evm-xreserve"] & active == {USDC_ROUTE, "xreserve:arc/usdc->aleo/usdcx"} + assert by_case["aleo-xreserve"] & active == {"xreserve:aleo/usdcx->ethereum/usdc", "xreserve:aleo/usdcx->arc/usdc"} def test_default_amount_is_one_atomic_unit_or_veils_literal(): @@ -1010,7 +1010,7 @@ 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 rise == {"xreserve:aleo/usdcx->ethereum/usdc", "xreserve:aleo-testnet/usdcx->sepolia/usdc", "xreserve:aleo/usdcx->arc/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) diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py index 71db9dea..c56c2dc3 100644 --- a/bridge-sdk/tests/test_registry.py +++ b/bridge-sdk/tests/test_registry.py @@ -20,25 +20,25 @@ 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 REG.version == "2026-09-28.cctp-arc.1" + assert len(REG.chains()) == 9 and len(REG.assets()) == 22 + assert len(REG.routes(include_unavailable=True)) == 30 + assert len(REG.routes()) == 30 # 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()] == ["aleo", "ethereum", "arc", "solana", "base", "arbitrum", "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("ethereum").protocol_domains == {"xreserve": 0, "hyperlane": 1, "cctp": 0} assert REG.chain("solana").protocol_domains == {"hyperlane": 1399811149} - assert REG.chain("base").protocol_domains == {} and REG.chain("hyperevm").native_symbol == "HYPE" + assert REG.chain("base").protocol_domains == {"cctp": 6} 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} + assert REG.chain("sepolia").protocol_domains == {"hyperlane": 11155111, "xreserve": 0} with pytest.raises(RouteNotFoundError): REG.chain("bitcoin") @@ -87,7 +87,8 @@ def test_usdcx_only_via_xreserve_and_others_via_hyperlane(): def test_xreserve_routes(): xr = REG.routes(bridge_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"] + "xreserve:sepolia/usdc->aleo-testnet/usdcx", "xreserve:aleo-testnet/usdcx->sepolia/usdc", + "xreserve:arc/usdc->aleo/usdcx", "xreserve:aleo/usdcx->arc/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) @@ -245,14 +246,14 @@ def test_metadata_required_routes(): def test_route_filters_follow_veil_get_routes(): # veil getRoutes: protocol / sourceChainId / destinationChainId / symbol — chain ids, never asset refs. - assert [r.id for r in REG.routes(source_chain="aleo", bridge_protocol="xreserve")] == ["xreserve:aleo/usdcx->ethereum/usdc"] + assert [r.id for r in REG.routes(source_chain="aleo", bridge_protocol="xreserve")] == ["xreserve:aleo/usdcx->ethereum/usdc", "xreserve:aleo/usdcx->arc/usdc"] assert [r.id for r in REG.routes(destination_chain="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(environment="testnet")) == 2 and len(REG.routes(environment="mainnet")) == 28 assert len(REG.routes(source_chain="solana")) == 2 # SOL deposit + metadata-required ALEO assert len(REG.routes(source_chain="SOLANA", destination_chain="Aleo")) == 2 # case-insensitive # the asset filters narrow a chain pair to one asset on either side assert [r.id for r in REG.routes(source_chain="aleo", source_asset="wbtc")] == ["hyperlane:aleo/wbtc->ethereum/wbtc"] - assert [r.id for r in REG.routes(destination_chain="ethereum", destination_asset="usdc")] == ["xreserve:aleo/usdcx->ethereum/usdc"] + assert [r.id for r in REG.routes(destination_chain="ethereum", destination_asset="usdc")] == ["xreserve:aleo/usdcx->ethereum/usdc", "cctp:arc/usdc->ethereum/usdc"] with pytest.raises(TypeError): REG.routes("aleo") # keyword-only: no positional selectors @@ -344,6 +345,6 @@ def test_validation_failures(): 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 len(data.CHAINS) == 9 and len(data.ASSETS) == 22 and len(data.ROUTES) == 30 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_registry_compatibility.py b/bridge-sdk/tests/test_registry_compatibility.py new file mode 100644 index 00000000..13fa06aa --- /dev/null +++ b/bridge-sdk/tests/test_registry_compatibility.py @@ -0,0 +1,94 @@ +"""Legacy deployment fingerprints must survive additions, but never deployment changes.""" +import json +from dataclasses import replace +from pathlib import Path + +import pytest + +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry, validate_registry +from aleo_bridge.errors import ConfigurationError + +LEGACY = json.loads((Path(__file__).parent / 'fixtures/registry-2026-08-31-python.json').read_text()) + + +@pytest.mark.parametrize('route_id', [r['id'] for r in LEGACY['routes']]) +def test_legacy_routes_remain_compatible(route_id): + from aleo_bridge._registry_compatibility import is_registry_version_compatible + assert is_registry_version_compatible(DEFAULT_REGISTRY, LEGACY['version'], route_id) + + +@pytest.mark.parametrize('route_id', [r['id'] for r in LEGACY['routes']]) +def test_legacy_label_cannot_hide_changed_deployment(route_id): + from aleo_bridge._registry_compatibility import is_registry_version_compatible + r = DEFAULT_REGISTRY + changed = Registry(r.version, r.chains(), r.assets(), [ + replace(v, metadata={**v.metadata, 'changedDeployment': 'unreviewed'}) if v.id == route_id else v + for v in r.routes(include_unavailable=True)]) + assert not is_registry_version_compatible(changed, LEGACY['version'], route_id) + + +@pytest.mark.parametrize('field,value', [('decimals', 18), ('availability', 'disabled'), ('domain', 99)]) +def test_legacy_route_rejects_asset_chain_or_availability_change(field, value): + from aleo_bridge._registry_compatibility import is_registry_version_compatible + r = DEFAULT_REGISTRY + route_id = 'xreserve:ethereum/usdc->aleo/usdcx' + assets = [replace(a, decimals=value) if field == 'decimals' and a.id == 'ethereum/usdc' else a for a in r.assets()] + routes = [replace(v, availability=value) if field == 'availability' and v.id == route_id else v + for v in r.routes(include_unavailable=True)] + chains = [replace(c, protocol_domains={**c.protocol_domains, 'xreserve': value}) + if field == 'domain' and c.id == 'ethereum' else c for c in r.chains()] + assert not is_registry_version_compatible(Registry(r.version, chains, assets, routes), LEGACY['version'], route_id) + + +def test_unknown_versions_and_new_routes_cannot_use_legacy_label(): + from aleo_bridge._registry_compatibility import is_registry_version_compatible + assert not is_registry_version_compatible(DEFAULT_REGISTRY, 'unknown', LEGACY['routes'][0]['id']) + assert not is_registry_version_compatible(DEFAULT_REGISTRY, LEGACY['version'], 'cctp:ethereum/usdc->arc/usdc') + + +@pytest.mark.parametrize('chain,domain,chain_id', [('ethereum', 0, 1), ('base', 6, 8453), ('arbitrum', 3, 42161)]) +def test_cctp_routes_bind_both_chain_domains(chain, domain, chain_id): + r = DEFAULT_REGISTRY + inbound = r.find_route(source_chain=chain, source_asset='usdc', destination_chain='arc') + outbound = r.find_route(source_chain='arc', source_asset='usdc', destination_chain=chain) + assert inbound.protocol == outbound.protocol == 'cctp' + assert (inbound.meta_int('sourceDomain'), inbound.meta_int('destinationDomain')) == (domain, 26) + assert (outbound.meta_int('sourceChainId'), outbound.meta_int('destinationChainId')) == (5042, chain_id) + assert r.asset('arc/usdc').decimals == 6 + assert r.asset('arc/usdc').locator.value == '0x3600000000000000000000000000000000000000' + + +@pytest.mark.parametrize('domain', [None, True, 99]) +def test_cctp_registry_refuses_missing_bool_or_mismatched_domain(domain): + r = DEFAULT_REGISTRY + chains = [replace(c, protocol_domains={'cctp': domain, 'xreserve': 26}) if c.id == 'arc' else c + for c in r.chains()] + with pytest.raises(ConfigurationError, match='domain'): + validate_registry(Registry(r.version, chains, r.assets(), r.routes(include_unavailable=True))) + + +def test_legacy_prepared_checkpoint_recovers_without_network_or_signing(): + from aleo_bridge.lifecycle import recover + from tests.test_recover import _aleo_eth_checkpoint + from tests.fakes.fake_bridge import FakeBridge + b = FakeBridge(ethereum=False) + serialized = json.dumps({'type':'execute','id':'at1prepared','fee':{}}) + _, cp = _aleo_eth_checkpoint(b,preparedTransaction={'transactionId':'at1prepared','serializedTransaction':serialized}) + cp['route']['registryVersion'] = LEGACY['version'] + result = recover(b,cp) + assert result.next == 'resume' + assert result.receipt.protocol_state['preparedTransaction'] == serialized + assert b.calls == [] and b.events == [] + + +def test_legacy_plan_still_tracks_terminal_delivery_without_provider_reads(): + from aleo_bridge.lifecycle import get_status, prepare + from aleo_bridge.types import Receipt, Status + from tests.fakes.fake_bridge import FakeBridge, EVM_ADDRESS + b = FakeBridge() + plan = prepare(b.registry,source_chain='aleo',source_asset='eth',destination_chain='ethereum', + amount='0.1',recipient=EVM_ADDRESS) + plan = replace(plan,registry_version=LEGACY['version']) + receipt = Receipt('old','hyperlane',Status.COMPLETED,protocol_state={'routeId':plan.route_id}) + assert get_status(b,plan,receipt) is receipt + assert b.calls == [] diff --git a/bridge-sdk/tests/test_sol_bridge.py b/bridge-sdk/tests/test_sol_bridge.py index 38c9ac67..cb551b0a 100644 --- a/bridge-sdk/tests/test_sol_bridge.py +++ b/bridge-sdk/tests/test_sol_bridge.py @@ -79,7 +79,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): + def capture_init(self, aleo, *, ethereum=None, solana=None, environment=None, registry=None, checkpoints=None, evm=None): seen["solana"] = solana monkeypatch.setattr(Bridge, "__init__", capture_init)