From 87cf57677b4b9e161961c82caab46282a493036e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 5 Nov 2026 08:10:41 -0500 Subject: [PATCH] feat(bridge): add Verulink ALEO-BSC support --- bridge-sdk/AGENTS.md | 31 +- bridge-sdk/README.md | 43 +++ bridge-sdk/codegen/gen_context.py | 9 +- bridge-sdk/examples/README.md | 40 +++ bridge-sdk/examples/verulink_roundtrip.py | 187 +++++++++++ bridge-sdk/pyproject.toml | 3 +- bridge-sdk/python/aleo_bridge/AGENTS.md | 31 +- bridge-sdk/python/aleo_bridge/__init__.py | 4 +- .../python/aleo_bridge/_evm_connections.py | 7 +- bridge-sdk/python/aleo_bridge/_plan.py | 8 + .../aleo_bridge/_registry_compatibility.py | 48 ++- .../python/aleo_bridge/_registry_data.py | 17 +- .../python/aleo_bridge/_verulink_abi.py | 48 +++ .../aleo_bridge/_verulink_attestation.py | 125 ++++++++ .../python/aleo_bridge/_verulink_context.py | 126 ++++++++ .../aleo_bridge/_verulink_deployment.py | 21 ++ .../python/aleo_bridge/_verulink_source.py | 219 +++++++++++++ .../python/aleo_bridge/_verulink_status.py | 148 +++++++++ .../python/aleo_bridge/_verulink_wire.py | 123 +++++++ bridge-sdk/python/aleo_bridge/agent.py | 27 +- bridge-sdk/python/aleo_bridge/checkpoint.py | 2 + bridge-sdk/python/aleo_bridge/client.py | 22 +- bridge-sdk/python/aleo_bridge/lifecycle.py | 50 ++- bridge-sdk/python/aleo_bridge/pause.py | 28 +- bridge-sdk/python/aleo_bridge/profile.py | 2 +- bridge-sdk/python/aleo_bridge/types.py | 16 +- bridge-sdk/python/aleo_bridge/verulink.py | 297 +++++++++++++++++ bridge-sdk/tests/fakes/fake_verulink.py | 106 +++++++ .../tests/fixtures/verulink-mainnet.json | 299 ++++++++++++++++++ bridge-sdk/tests/live/cases.py | 15 +- bridge-sdk/tests/live/config.py | 5 +- bridge-sdk/tests/live/test_lifecycle_live.py | 9 + .../tests/live/test_verulink_readonly.py | 28 ++ bridge-sdk/tests/test_agent.py | 10 +- bridge-sdk/tests/test_arc_xreserve.py | 6 +- bridge-sdk/tests/test_cctp_quote.py | 6 +- bridge-sdk/tests/test_client.py | 4 +- bridge-sdk/tests/test_evm_connections.py | 16 + bridge-sdk/tests/test_hyperlane.py | 10 + bridge-sdk/tests/test_live_helpers.py | 5 +- bridge-sdk/tests/test_registry.py | 20 +- bridge-sdk/tests/test_verulink.py | 69 ++++ bridge-sdk/tests/test_verulink_example.py | 144 +++++++++ bridge-sdk/tests/test_verulink_lifecycle.py | 299 ++++++++++++++++++ bridge-sdk/tests/test_verulink_pause.py | 74 +++++ bridge-sdk/tests/test_verulink_wire.py | 158 +++++++++ 46 files changed, 2894 insertions(+), 71 deletions(-) create mode 100644 bridge-sdk/examples/verulink_roundtrip.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_abi.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_attestation.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_context.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_deployment.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_source.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_status.py create mode 100644 bridge-sdk/python/aleo_bridge/_verulink_wire.py create mode 100644 bridge-sdk/python/aleo_bridge/verulink.py create mode 100644 bridge-sdk/tests/fakes/fake_verulink.py create mode 100644 bridge-sdk/tests/fixtures/verulink-mainnet.json create mode 100644 bridge-sdk/tests/live/test_verulink_readonly.py create mode 100644 bridge-sdk/tests/test_verulink.py create mode 100644 bridge-sdk/tests/test_verulink_example.py create mode 100644 bridge-sdk/tests/test_verulink_lifecycle.py create mode 100644 bridge-sdk/tests/test_verulink_pause.py create mode 100644 bridge-sdk/tests/test_verulink_wire.py diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index 5ba60537..f3fc5272 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -3,12 +3,12 @@ > 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, Arc, Base, Arbitrum and Solana -over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments +Typed Python client that moves assets between Aleo, Ethereum, BSC, Arc, Base, Arbitrum and Solana +over reviewed Hyperlane, Verulink, 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-09-30.hyperlane-bat-usdg-zec.1`. +Registry version `2026-11-04.verulink-bsc.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. @@ -16,6 +16,11 @@ 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. +Verulink moves public ALEO between Aleo and BSC; configure `evm={"bsc": Ethereum(...)}`. +Both directions require `complete(progress)` with destination gas, without a secret nonce. +Fee rates, pause state and peers are read live; unknown gas costs are None, not zero. +An approval-only BSC checkpoint requires source-hash reconciliation, never another burn. +Holding is not delivery; its checkpoint remains available for review and recovery. Runnable examples ship in the package: start with `python -m aleo_bridge.examples.quote_transfer --help`. @@ -68,7 +73,8 @@ 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`` + ``destination_chain`` (``"ethereum"``, ``"usdc"``, ``"aleo"``), adding -``destination_asset`` / ``bridge_protocol`` (``"xreserve"`` | ``"hyperlane"``) +``destination_asset`` / ``bridge_protocol`` (``"xreserve"``, ``"hyperlane"``, +``"cctp"``, or ``"verulink"``) only when more than one route fits — or ``route=`` with a ``Route`` from ``routes()`` or its id. Give exactly one of ``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is the destination-chain @@ -76,11 +82,15 @@ address. ``mint_mode`` (xReserve into Aleo only): ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` — you finish it yourself with ``complete`` and must keep ``secret_nonce``. Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / -solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with +solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve / +evm-cctp / aleo-verulink / evm-verulink) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either end reads paused or cannot be read (see ``pause_status``). +Verulink supports public ALEO +between Aleo and BSC; both directions require a destination claim. +Unpriced network gas has ``Fee.amount=None``, never zero. ### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` @@ -138,9 +148,9 @@ end reads paused or cannot be read. ### `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. +Claim a private USDCx, native-USDC CCTP, or public Verulink destination. -Requires the same ``secret_nonce`` given to ``execute``; the SDK never +Private xReserve 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. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` @@ -148,6 +158,8 @@ to explicitly authorize fallback for stalled forwarding; an already submitted destination transaction is observed rather than repeated. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on the destination side reads paused or cannot be read. +Verulink requires a destination signer and native gas, with no secret +nonce. A holding result retains its checkpoint for review and recovery. ### `pending(self) -> 'list'` @@ -170,7 +182,8 @@ Read every operator pause switch governing ``route`` (a ``Route``, its id, or a Both ends are read unless ``sides`` narrows it: the ``pause`` mapping and enrolled router of the Aleo warp program (plus the ARC-20 token's ``pause__``), the USDCx bridge and stablecoin pauses, ``paused()`` on xReserve and the CCTP transmitter, any ``PausableHook`` / ``PausableIsm`` in the -Ethereum Mailbox's trees, and the Solana token PDA's enrolled router. Nothing is cached and the +Ethereum Mailbox's trees, the Solana token PDA's enrolled router, and Verulink's Aleo bridge/service +settings and BSC bridge/service pauses. Nothing is cached and the result never raises for a pause: ``status.paused`` / ``status.unknown`` say so and ``status.reason`` names the switch. ``quote``, ``execute``, ``resume`` and ``complete`` run this same check and refuse with ``RoutePausedError`` or ``PauseCheckError``. A far-side EVM chain with no @@ -460,6 +473,8 @@ tolerated, so re-stating the plan's own amount is harmless). Without a plan, ``r | `cctp:arc/usdc->base/usdc` | cctp | mainnet | active | | `cctp:arbitrum/usdc->arc/usdc` | cctp | mainnet | active | | `cctp:arc/usdc->arbitrum/usdc` | cctp | mainnet | active | +| `verulink:aleo/aleo->bsc/aleo` | verulink | mainnet | active | +| `verulink:bsc/aleo->aleo/aleo` | verulink | 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 95460e0d..f97f74b3 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -3,6 +3,7 @@ Bring assets from Ethereum or Solana to Aleo for payments and applications, or withdraw them back to their source chain. Hyperlane carries ETH, WBTC, USDT, SOL, BAT, USDG, and ZEC; Circle xReserve connects Ethereum USDC with Aleo USDCx. +Verulink connects public native ALEO with wrapped ALEO on BSC. Review the cost before sending, monitor whether the recipient received the funds, and recover an interrupted transfer without making another deposit. @@ -57,6 +58,8 @@ Choose the source chain and asset first, then find the corresponding destination | Aleo | USDG | Ethereum | USDG | Hyperlane | | Aleo | BAT, USDG, or ZEC | Solana | BAT, USDG, or ZEC | Hyperlane | | Aleo | USDCx | Ethereum | USDC | Circle xReserve | +| Aleo | ALEO | BSC | Wrapped ALEO | Verulink | +| BSC | Wrapped ALEO | Aleo | ALEO | Verulink | | Ethereum | ETH | Aleo | ETH | Hyperlane | | Ethereum | WBTC | Aleo | WBTC | Hyperlane | | Ethereum | USDT | Aleo | USDT | Hyperlane | @@ -73,6 +76,46 @@ BAT has 18 decimals on Ethereum and Aleo but 8 on Solana, so amounts on a Solana route must fit 8 decimals. On Aleo these three are ARC-22 tokens with their own shield programs, so `shield` and `unshield` work for them like USDCx. +## Public ALEO on BSC + +Verulink moves public native ALEO to BSC and returns wrapped ALEO to a public +Aleo balance. Both directions need a destination claim and gas on both chains: +ALEO on Aleo and BNB on BSC. Add the BSC connection to the client: + +```python +bridge = Bridge(aleo, evm={"bsc": Ethereum(bsc_rpc_url, private_key=evm_private_key)}, + checkpoints=FileCheckpointStore("./bridge-checkpoints")) +quote = bridge.quote(source_chain="aleo", source_asset="aleo", destination_chain="bsc", + amount="20", recipient=bsc_recipient) +print(quote.amount_out, quote.fees) +``` + +This uses the `Bridge`, `Ethereum`, and `FileCheckpointStore` imports and the +Aleo client shown in Setup. To return wrapped ALEO, swap the source and +destination chains and use an Aleo recipient. Private and record mint modes +are unavailable for Verulink. + +Quotes read current fees, limits, peer contracts and pause state. The protocol +fee is charged in the source ALEO asset. Network gas is unpriced +(`Fee.amount=None`); it is an additional cost. Execute `quote.plan` to retain +the quoted minimum output, then use `wait` and `complete(progress)` when a +claim is ready. Verulink completion needs no secret nonce. + +Save checkpoints before continuing after an interruption. Aleo recovery +rebroadcasts the saved prepared transaction. An approval-only BSC checkpoint +requires the wallet's source transaction hash before continuing; repeating +the burn could send funds twice. After a claim is submitted, recover or wait +using its transaction ID. + +Completion requires the exact recipient delivery. A consumed packet alone +stays pending, and a screening hold is reported as failed with +`receipt.protocol_state["held"] == True`. Its checkpoint remains stored +because the recipient has not received the funds. + +The [round-trip example](examples/verulink_roundtrip.py) previews both legs and +can resume a saved journey. Its return leg spends the verified amount received. +Checkpoints from the preceding registry remain valid for unchanged routes. + ## Setup To bridge assets to and from Aleo, create a `Bridge` client with the accounts diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py index 5ddfbb7e..2a87a23f 100644 --- a/bridge-sdk/codegen/gen_context.py +++ b/bridge-sdk/codegen/gen_context.py @@ -185,8 +185,8 @@ 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, Arc, Base, Arbitrum and Solana", - "over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments", + "Typed Python client that moves assets between Aleo, Ethereum, BSC, Arc, Base, Arbitrum and Solana", + "over reviewed Hyperlane, Verulink, 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.", @@ -198,6 +198,11 @@ def render() -> str: "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.", + "Verulink moves public ALEO between Aleo and BSC; configure `evm={\"bsc\": Ethereum(...)}`.", + "Both directions require `complete(progress)` with destination gas, without a secret nonce.", + "Fee rates, pause state and peers are read live; unknown gas costs are None, not zero.", + "An approval-only BSC checkpoint requires source-hash reconciliation, never another burn.", + "Holding is not delivery; its checkpoint remains available for review and recovery.", "", "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 f18b603d..1ae3d27f 100644 --- a/bridge-sdk/examples/README.md +++ b/bridge-sdk/examples/README.md @@ -42,6 +42,46 @@ real funds. Review a quote before submitting a new transfer. | [recover_from_journal.py](recover_from_journal.py) | Find a saved transfer, check progress, or submit its remaining step. | | [recover_without_files.py](recover_without_files.py) | Restore monitoring of a confirmed Ethereum-to-Aleo WBTC dispatch from explorer details. | | [shield_assets.py](shield_assets.py) | Convert an existing public Aleo balance to an encrypted private record. | +| [verulink_roundtrip.py](verulink_roundtrip.py) | Preview or recover public ALEO → BSC → Aleo with a checkpoint for each leg. | + +## ALEO through BSC + +Preview the Verulink round trip using public addresses: + +```sh +python -m aleo_bridge.examples.verulink_roundtrip \ + --aleo-address "$ALEO_ADDRESS" --bsc-address "$BSC_ADDRESS" --amount 20 +``` + +The preview loads no keys and writes no journey file. Both directions use +public balances. Execution requires `ALEO_PRIVATE_KEY`, `EVM_PRIVATE_KEY`, +`--execute`, and the operator's own +`BRIDGE_LIVE_MAINNET_EXECUTE=I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS` +acknowledgement. The signers must match the two addresses. Keep ALEO for fees +and at least 0.001 BNB before starting; that BNB reserve is a budget, not a +gas estimate. Override `ALEO_RPC_URL` and `BSC_RPC_URL` for other mainnet providers. + +Execution saves to `~/.aleo-bridge/verulink-roundtrip.json`, or `--state PATH`. +Run the same arguments to recover that journey. The second leg spends the +verified packet amount received on BSC. A lock prevents concurrent runners; +after a process crash, confirm that its owner has stopped before removing +the adjacent `.lock` file. + +A wallet boundary is marked before submission. If its response is lost +without a transaction ID, the example stops for reconciliation; it never +starts another source transfer or claim on a retry. Retain the state file, +find the transaction hash in the wallet, and update that leg's checkpoint. +An approval-only BSC checkpoint cannot authorize another burn. Screening +holds keep their checkpoint and are not reported as delivered. + +From the checkout, public deployment checks need no accounts: + +```sh +BRIDGE_VERULINK_READ_ONLY=1 python -m pytest -m live tests/live/test_verulink_readonly.py +``` + +Funded live coverage uses the existing opt-in gates with the +`aleo-verulink` and `evm-verulink` case names. No funded test runs by default. ## Review a transfer diff --git a/bridge-sdk/examples/verulink_roundtrip.py b/bridge-sdk/examples/verulink_roundtrip.py new file mode 100644 index 00000000..ec39948d --- /dev/null +++ b/bridge-sdk/examples/verulink_roundtrip.py @@ -0,0 +1,187 @@ +"""Preview or recover a public ALEO → BSC → Aleo round trip.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import tempfile +import time +from typing import Any + +from aleo_bridge import Bridge, BridgeError, Ethereum, create_checkpoint +from aleo_bridge.units import format_decimal_amount, parse_decimal_amount + +ACK = "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS" +BNB_RESERVE = 10**15 # 0.001 BNB budget for claim, approval and burn; not a fee estimate. +ROUTES = ("verulink:aleo/aleo->bsc/aleo", "verulink:bsc/aleo->aleo/aleo") + + +def require_execution_ack() -> None: + if os.environ.get("BRIDGE_LIVE_MAINNET_EXECUTE") != ACK: + raise BridgeError("Execution requires BRIDGE_LIVE_MAINNET_EXECUTE=" + ACK) + + +def build_bridge(*, execute: bool) -> Bridge: + from aleo import Aleo, HTTPProvider + if execute: + require_execution_ack() + aleo = Aleo(HTTPProvider(os.environ.get("ALEO_RPC_URL", "https://edge.provable.com/api"), network="mainnet")) + if execute: + aleo.default_account = aleo.account.from_private_key(os.environ["ALEO_PRIVATE_KEY"]) + bsc = Ethereum(os.environ.get("BSC_RPC_URL", "https://bsc-dataseed.bnbchain.org"), + private_key=os.environ["EVM_PRIVATE_KEY"] if execute else None) + return Bridge(aleo, evm={"bsc": bsc}) + + +def _save(path: Path, state: dict[str, Any]) -> None: + fd, name = tempfile.mkstemp(dir=path.parent, prefix=".verulink-") + try: + with os.fdopen(fd, "w") as stream: + json.dump(state, stream, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + if os.name != "nt": + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + if os.path.exists(name): + os.unlink(name) + + +def run_roundtrip(bridge: Bridge, *, aleo_address: str, bsc_address: str, amount: str, + state_path: Path, execute: bool = False, timeout: float = 1200) -> None: + """Quote both legs, or continue one journal with submission markers at each wallet boundary. + + The return spends the verified packet amount received on BSC. The same state + file and arguments resume the journey; an uncertain submission requires its + transaction hash before continuing. + """ + def quote(index: int, value: str): + return bridge.quote(route=ROUTES[index], amount=value, + sender=aleo_address if index == 0 else bsc_address, + recipient=bsc_address if index == 0 else aleo_address) + + if not execute: + outbound = quote(0, amount) + inbound = quote(1, outbound.amount_out) + print("BSC received estimate:", outbound.amount_out, "ALEO returned estimate:", inbound.amount_out) + return + require_execution_ack() + path = Path(state_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + lock_path = Path(str(path) + ".lock") + try: + fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as exc: + raise BridgeError("Journey lock exists; confirm its owner has stopped before removing it") from exc + try: + with os.fdopen(fd, "w") as lock: + lock.write(str(os.getpid())) + state = json.loads(path.read_text()) if path.exists() else { + "version": 1, "aleo": aleo_address, "bsc": bsc_address, "amount": amount, + "legs": [{"amount": amount}]} + if (state.get("version") != 1 or state.get("aleo") != aleo_address or state.get("bsc") != bsc_address + or state.get("amount") != amount or not isinstance(state.get("legs"), list) + or not 1 <= len(state["legs"]) <= 2): + raise BridgeError("Saved journey differs; use its original accounts and amount") + if (bridge.aleo_address() != aleo_address + or bridge.evm("bsc").conn.require_address().lower() != bsc_address.lower()): + raise BridgeError("Configured signers differ from the journey addresses") + if not state["legs"][0].get("source_attempted") and bridge.evm("bsc").conn.w3.eth.get_balance(bsc_address) < BNB_RESERVE: + raise BridgeError("Fund the BSC account with at least 0.001 BNB before committing ALEO") + for index in range(2): + if len(state["legs"]) == index: + state["legs"].append({"amount": format_decimal_amount(int(state["legs"][0]["received"]), 6)}) + leg = state["legs"][index] + expected = amount if index == 0 else format_decimal_amount(int(state["legs"][0]["received"]), 6) + if leg.get("amount") != expected: + raise BridgeError("Saved leg amount differs from the verified received amount") + + def checkpoint(cp: Any) -> None: + leg["checkpoint"] = cp.to_dict() + _save(path, state) + + if not leg.get("checkpoint"): + if leg.get("source_attempted"): + raise BridgeError("Source submission is uncertain; recover its wallet transaction hash before continuing") + quoted = quote(index, leg["amount"]) + if index == 0: + quote(1, quoted.amount_out) # The estimated return must clear its fees and minimum. + leg["source_attempted"] = True + _save(path, state) + bridge.execute(quoted.plan, on_checkpoint=checkpoint, timeout_seconds=timeout) + if not leg.get("checkpoint"): + raise BridgeError("Wallet returned without a checkpoint; reconcile before retrying") + progress = bridge.recover(leg["checkpoint"]) + sender = aleo_address if index == 0 else bsc_address + recipient = bsc_address if index == 0 else aleo_address + if (progress.plan.route_id != ROUTES[index] or progress.plan.amount_atomic != parse_decimal_amount(expected, 6) + or not isinstance(progress.plan.sender, str) or progress.plan.sender.lower() != sender.lower() + or progress.plan.recipient.lower() != recipient.lower()): + raise BridgeError("Saved checkpoint differs from the configured journey leg") + # Recheck completed legs too; the return budget is always backed by chain evidence. + deadline = time.monotonic() + timeout + while True: + print("Leg", index + 1, progress.next, progress.receipt.source_tx_id, progress.receipt.destination_tx_id) + if progress.next == "done": + net = progress.receipt.protocol_state.get("packet", {}).get("amount") + if not isinstance(net, str) or not net.isascii() or not net.isdecimal() or int(net) <= 0: + raise BridgeError("Completed transfer has no verified packet amount") + leg["received"] = net + checkpoint(create_checkpoint(progress.plan, progress.receipt, bridge.registry)) + break + if progress.next == "failed": + raise BridgeError(progress.error or "Verulink transfer failed or is held; retain the checkpoint") + if time.monotonic() >= deadline: + raise BridgeError("Polling timed out; recover the same journey") + if progress.next == "resume": + if not leg["checkpoint"].get("source", {}).get("preparedTransaction"): + raise BridgeError("Approval-only BSC recovery requires wallet reconciliation") + progress = bridge.resume(progress, on_checkpoint=checkpoint) + elif progress.next == "complete": + if leg.get("claim_attempted") and not leg["checkpoint"].get("destination", {}).get("preparedTransaction"): + raise BridgeError("Claim submission is uncertain; add its destination transaction hash before continuing") + leg["claim_attempted"] = True + _save(path, state) + progress = bridge.complete(progress, on_checkpoint=checkpoint) + else: + progress = bridge.wait(progress, timeout_seconds=max(0, deadline - time.monotonic()), + on_update=lambda update: checkpoint(create_checkpoint(update.plan, update.receipt, bridge.registry))) + checkpoint(create_checkpoint(progress.plan, progress.receipt, bridge.registry)) + print("Completed; ALEO returned:", format_decimal_amount(int(state["legs"][1]["received"]), 6), "State:", path) + finally: + lock_path.unlink() + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--aleo-address", required=True) + parser.add_argument("--bsc-address", required=True) + parser.add_argument("--amount", default="20", help="Public ALEO sent on the first leg.") + parser.add_argument("--state", default="~/.aleo-bridge/verulink-roundtrip.json") + parser.add_argument("--timeout", type=float, default=1200, help="Seconds per leg before returning with its saved checkpoint.") + parser.add_argument("--execute", action="store_true", help="Submit or recover both MAINNET legs; costs real funds.") + args = parser.parse_args(argv) + if args.execute: + require_execution_ack() + bridge = build_bridge(execute=args.execute) + run_roundtrip(bridge, aleo_address=args.aleo_address, bsc_address=args.bsc_address, amount=args.amount, + state_path=Path(args.state), execute=args.execute, timeout=args.timeout) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("Interrupted. Recover the same journey before submitting again.") + raise SystemExit(130) + except Exception as exc: + # Provider errors can contain credentials; never dump their request or traceback. + print(type(exc).__name__ + ": journey unfinished. Keep its state file and reconcile any uncertain transaction.") + raise SystemExit(2) diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml index b721516e..f16bd112 100644 --- a/bridge-sdk/pyproject.toml +++ b/bridge-sdk/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aleo-bridge-sdk" version = "0.8.0" -description = "Bridge Aleo, Ethereum, Arc, Base, Arbitrum and Solana assets with Hyperlane, Circle xReserve and CCTP" +description = "Bridge Aleo, Ethereum, BSC, Arc, Base, Arbitrum and Solana assets with Hyperlane, Verulink, Circle xReserve and CCTP" readme = "README.md" requires-python = ">=3.10" # PyNaCl is NOT optional: every Aleo-side execution goes through delegated proving @@ -50,6 +50,7 @@ packages = ["python/aleo_bridge"] "examples/bridge_wbtc.py" = "aleo_bridge/examples/bridge_wbtc.py" "examples/bridge_wbtc_to_ethereum.py" = "aleo_bridge/examples/bridge_wbtc_to_ethereum.py" "examples/quote_transfer.py" = "aleo_bridge/examples/quote_transfer.py" +"examples/verulink_roundtrip.py" = "aleo_bridge/examples/verulink_roundtrip.py" "examples/recover_from_journal.py" = "aleo_bridge/examples/recover_from_journal.py" "examples/recover_without_files.py" = "aleo_bridge/examples/recover_without_files.py" "examples/shield_assets.py" = "aleo_bridge/examples/shield_assets.py" diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index 5ba60537..f3fc5272 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -3,12 +3,12 @@ > 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, Arc, Base, Arbitrum and Solana -over reviewed Hyperlane, Circle xReserve and native-USDC CCTP deployments +Typed Python client that moves assets between Aleo, Ethereum, BSC, Arc, Base, Arbitrum and Solana +over reviewed Hyperlane, Verulink, 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-09-30.hyperlane-bat-usdg-zec.1`. +Registry version `2026-11-04.verulink-bsc.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. @@ -16,6 +16,11 @@ 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. +Verulink moves public ALEO between Aleo and BSC; configure `evm={"bsc": Ethereum(...)}`. +Both directions require `complete(progress)` with destination gas, without a secret nonce. +Fee rates, pause state and peers are read live; unknown gas costs are None, not zero. +An approval-only BSC checkpoint requires source-hash reconciliation, never another burn. +Holding is not delivery; its checkpoint remains available for review and recovery. Runnable examples ship in the package: start with `python -m aleo_bridge.examples.quote_transfer --help`. @@ -68,7 +73,8 @@ 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`` + ``destination_chain`` (``"ethereum"``, ``"usdc"``, ``"aleo"``), adding -``destination_asset`` / ``bridge_protocol`` (``"xreserve"`` | ``"hyperlane"``) +``destination_asset`` / ``bridge_protocol`` (``"xreserve"``, ``"hyperlane"``, +``"cctp"``, or ``"verulink"``) only when more than one route fits — or ``route=`` with a ``Route`` from ``routes()`` or its id. Give exactly one of ``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is the destination-chain @@ -76,11 +82,15 @@ address. ``mint_mode`` (xReserve into Aleo only): ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` — you finish it yourself with ``complete`` and must keep ``secret_nonce``. Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / -solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with +solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve / +evm-cctp / aleo-verulink / evm-verulink) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either end reads paused or cannot be read (see ``pause_status``). +Verulink supports public ALEO +between Aleo and BSC; both directions require a destination claim. +Unpriced network gas has ``Fee.amount=None``, never zero. ### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, compliance_proofs: 'ComplianceProofs' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` @@ -138,9 +148,9 @@ end reads paused or cannot be read. ### `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. +Claim a private USDCx, native-USDC CCTP, or public Verulink destination. -Requires the same ``secret_nonce`` given to ``execute``; the SDK never +Private xReserve 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. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` @@ -148,6 +158,8 @@ to explicitly authorize fallback for stalled forwarding; an already submitted destination transaction is observed rather than repeated. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on the destination side reads paused or cannot be read. +Verulink requires a destination signer and native gas, with no secret +nonce. A holding result retains its checkpoint for review and recovery. ### `pending(self) -> 'list'` @@ -170,7 +182,8 @@ Read every operator pause switch governing ``route`` (a ``Route``, its id, or a Both ends are read unless ``sides`` narrows it: the ``pause`` mapping and enrolled router of the Aleo warp program (plus the ARC-20 token's ``pause__``), the USDCx bridge and stablecoin pauses, ``paused()`` on xReserve and the CCTP transmitter, any ``PausableHook`` / ``PausableIsm`` in the -Ethereum Mailbox's trees, and the Solana token PDA's enrolled router. Nothing is cached and the +Ethereum Mailbox's trees, the Solana token PDA's enrolled router, and Verulink's Aleo bridge/service +settings and BSC bridge/service pauses. Nothing is cached and the result never raises for a pause: ``status.paused`` / ``status.unknown`` say so and ``status.reason`` names the switch. ``quote``, ``execute``, ``resume`` and ``complete`` run this same check and refuse with ``RoutePausedError`` or ``PauseCheckError``. A far-side EVM chain with no @@ -460,6 +473,8 @@ tolerated, so re-stating the plan's own amount is harmless). Without a plan, ``r | `cctp:arc/usdc->base/usdc` | cctp | mainnet | active | | `cctp:arbitrum/usdc->arc/usdc` | cctp | mainnet | active | | `cctp:arc/usdc->arbitrum/usdc` | cctp | mainnet | active | +| `verulink:aleo/aleo->bsc/aleo` | verulink | mainnet | active | +| `verulink:bsc/aleo->aleo/aleo` | verulink | 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 209d33af..42119445 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -45,7 +45,7 @@ def _require_aleo_sdk() -> None: CctpOptions, EvmCctpQuote, CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, GasQuote, MintReceipt, PauseStatus, PauseSwitch, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, - SolanaHyperlaneQuote, Status, Step, to_progress, + SolanaHyperlaneQuote, Status, Step, to_progress, VerulinkQuote, ) from ._calls import AleoCall, EvmCall, SolCall # noqa: E402 from .checkpoint import (Checkpoint, CheckpointLoadResult, CheckpointProblem, CheckpointStore, FileCheckpointStore, # noqa: E402 @@ -73,7 +73,7 @@ def _require_aleo_sdk() -> None: "CctpOptions", "EvmCctpQuote", "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", "Fee", "GasQuote", "MintReceipt", "PauseStatus", "PauseSwitch", "Plan", "PreparedTx", "PrivacyReceipt", - "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "VerulinkQuote", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", "ComplianceProof", "ComplianceProofs", "MerkleProofInput", "MerkleProofPair", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", diff --git a/bridge-sdk/python/aleo_bridge/_evm_connections.py b/bridge-sdk/python/aleo_bridge/_evm_connections.py index 140416f2..f9912606 100644 --- a/bridge-sdk/python/aleo_bridge/_evm_connections.py +++ b/bridge-sdk/python/aleo_bridge/_evm_connections.py @@ -9,7 +9,7 @@ from .eth import Ethereum from .registry import Registry -RPC_VARIABLES = {"arc": "ARC_RPC_URL", "base": "BASE_RPC_URL", "arbitrum": "ARBITRUM_RPC_URL"} +RPC_VARIABLES = {"arc": "ARC_RPC_URL", "base": "BASE_RPC_URL", "arbitrum": "ARBITRUM_RPC_URL", "bsc": "BSC_RPC_URL"} def coerce(value: Any) -> Ethereum: @@ -30,6 +30,11 @@ def normalize(registry: Registry, environment: str, connections: Mapping[str, An 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 == "bsc": + from web3.middleware import ExtraDataToPOAMiddleware + onion = conn.w3.middleware_onion + if ExtraDataToPOAMiddleware not in onion: + onion.inject(ExtraDataToPOAMiddleware, layer=0) 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 diff --git a/bridge-sdk/python/aleo_bridge/_plan.py b/bridge-sdk/python/aleo_bridge/_plan.py index 090b62d8..f02f1ef6 100644 --- a/bridge-sdk/python/aleo_bridge/_plan.py +++ b/bridge-sdk/python/aleo_bridge/_plan.py @@ -55,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 == "verulink": + if {source_family, destination_family} != {"aleo", "evm"}: + raise UnsupportedRouteError("Verulink requires Aleo and EVM chains") + steps = tuple([Step("source-approval", "approve", wallet, False)] if source_family == "evm" else []) + ( + Step("source-transfer", "dispatch", wallet, True), + Step("packet-attestation", "wait-attestation", "protocol", False), + Step("destination-claim", "claim", WALLET_EXECUTOR_BY_FAMILY[destination_family], False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) elif route.protocol == "cctp": if (source_family, destination_family) != ("evm", "evm"): raise BridgeError("CCTP requires two EVM chains") diff --git a/bridge-sdk/python/aleo_bridge/_registry_compatibility.py b/bridge-sdk/python/aleo_bridge/_registry_compatibility.py index 51c827d5..162bb336 100644 --- a/bridge-sdk/python/aleo_bridge/_registry_compatibility.py +++ b/bridge-sdk/python/aleo_bridge/_registry_compatibility.py @@ -22,7 +22,8 @@ LEGACY_VERSION = "2026-08-31.solana-deposits.1" PREVIOUS_VERSION = "2026-09-28.cctp-arc.1" -CURRENT_VERSION = "2026-09-30.hyperlane-bat-usdg-zec.1" +PRE_VERULINK_VERSION = "2026-09-30.hyperlane-bat-usdg-zec.1" +CURRENT_VERSION = "2026-11-04.verulink-bsc.1" def route_fingerprint(registry: Registry, route_id: str) -> str: @@ -127,8 +128,53 @@ def is_registry_version_compatible(registry: Registry, version: str | None, rout "cctp:arc/usdc->arbitrum/usdc": "4df2493eeeae9ced9b5089c8f0cbe4d25555ec5c2b19b7f645acf1bc655d228c" } +# Captured from Python SDK d41f4c6 before adding Verulink; old plans cannot name new routes. +PRE_VERULINK_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:ethereum/bat->aleo/bat": "0008853ddd0c14288d7903a1f90c6d7935894f0b7ef532b5570adc6748510014", + "hyperlane:aleo/bat->ethereum/bat": "f81a5f53866a6ccd66a88bddf2343fff52b5171e97e0e0405cf4d4dc50e65c97", + "hyperlane:ethereum/usdg->aleo/usdg": "367179b4dd63b906ef728332222f1b588e9b73537048a19bf4cb3f26d530ea47", + "hyperlane:aleo/usdg->ethereum/usdg": "9727df123e419862b0105d99c76bef729852cf2a8cfb9b8c0b4ed94952dbd258", + "hyperlane:solana/sol->aleo/sol": "f8dccf00884bf41dc4ae702ec40366dacc47c65c2aa5985e7638ad11139098fc", + "hyperlane:aleo/sol->solana/sol": "ae205673cb91518ec0e6aeea2a465bd15450e880809ee642226640690e461f80", + "hyperlane:solana/bat->aleo/bat": "080f29e2c08fe9e42a4b01ff6e99f06f88819c94292ab9378e2a659b1f953ab9", + "hyperlane:aleo/bat->solana/bat": "3668b5f90f66ebabe92c14fc6bef9e552d51a96fb1258460f5a99e8329d8f4e9", + "hyperlane:solana/usdg->aleo/usdg": "c44d166d1ce707d38ec0475d72702a520f0b750a599c7ac3972ec0bb752e7701", + "hyperlane:aleo/usdg->solana/usdg": "fbf10573519c571bcb7974e1b97a23b566c6af00ee377cf7a19a65f72a6b37d5", + "hyperlane:solana/zec->aleo/zec": "896ee1945da91116623d0055351ecaec35070e3831d43a88428474e5628f62ab", + "hyperlane:aleo/zec->solana/zec": "9c3aa35f88f7b6decd01f3e9b1bef2915221a2155b3b2caf9c6c70e1d0bec61c", + "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", + "xreserve:arc/usdc->aleo/usdcx": "a362fbc8f8e431e8103a7c4ff8bf8ea98ebcd774ee78e8e8f893e4e3a6cc5778", + "xreserve:aleo/usdcx->arc/usdc": "b1d97486bc55030b41f350fcee9693a6412a0ccd7d85e963ff6ffcfe8d29e291", + "cctp:ethereum/usdc->arc/usdc": "57da199c23adec3a7a43391107e6260deb253cc03037dccb75919bd34f5098c7", + "cctp:arc/usdc->ethereum/usdc": "3862c7c92f0794a0c7a676107e08901ebb3ae77ce8da10c63abd08bfd9d12633", + "cctp:base/usdc->arc/usdc": "bf845c10ccc83dd2c4387e89de311e9c42d40d0ba6a8b7f457fa75531778f80e", + "cctp:arc/usdc->base/usdc": "0b8c47359d34c4575652b650f047d1a6da3ccb1fc802f7fec843c8e5e7d7d439", + "cctp:arbitrum/usdc->arc/usdc": "adffa5d93d5aad95f1055c3a8e10417e3e0f6cf361cc77dc5b56c32e1d6413fe", + "cctp:arc/usdc->arbitrum/usdc": "4df2493eeeae9ced9b5089c8f0cbe4d25555ec5c2b19b7f645acf1bc655d228c" +} + #: Every reviewed prior snapshot a newer registry still accepts unchanged routes from. KNOWN_ROUTE_HASHES: dict[str, dict[str, str]] = { + PRE_VERULINK_VERSION: PRE_VERULINK_ROUTE_HASHES, LEGACY_VERSION: LEGACY_ROUTE_HASHES, PREVIOUS_VERSION: PREVIOUS_ROUTE_HASHES, } diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py index 8238236a..65a7ae6f 100644 --- a/bridge-sdk/python/aleo_bridge/_registry_data.py +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -4,7 +4,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-09-30.hyperlane-bat-usdg-zec.1" +REGISTRY_VERSION = "2026-11-04.verulink-bsc.1" EVM_ADDRESS = "^0x[0-9a-fA-F]{40}$" SOLANA_ADDRESS = "^[1-9A-HJ-NP-Za-km-z]{32,44}$" @@ -12,7 +12,7 @@ CHAINS = [ {"id": "aleo", "displayName": "Aleo", "family": "aleo", "environment": "mainnet", "nativeCurrencySymbol": "ALEO", - "protocolDomains": {"xreserve": 10002, "hyperlane": 1634493807}}, + "protocolDomains": {"xreserve": 10002, "hyperlane": 1634493807, "verulink": 6694886634401}}, {"id": "ethereum", "displayName": "Ethereum", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH", "protocolDomains": {"xreserve": 0, "hyperlane": 1, "cctp": 0}}, {"id": "arc", "displayName": "Arc", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "USDC", @@ -34,6 +34,7 @@ # connection for (never for signing, balances or anything else). Kept off the Chain entries so the # legacy route fingerprints in _registry_compatibility stay byte-identical. PUBLIC_READ_RPC_URLS = { + "bsc": ["https://bsc-dataseed.bnbchain.org"], "ethereum": ["https://ethereum-rpc.publicnode.com", "https://1rpc.io/eth"], "arc": ["https://rpc.mainnet.arc.io"], "base": ["https://base-rpc.publicnode.com", "https://1rpc.io/base"], @@ -685,3 +686,15 @@ def _pair(protocol: str, environment: str, left: str, right: str, availability: f"{_dst}/usdc", "active", f"cctp-v2-{_src}-{_dst}", {**CCTP_MAINNET_METADATA, "sourceChainId": _sid, "destinationChainId": _did, "sourceDomain": _sd, "destinationDomain": _dd})) + +from ._verulink_deployment import METADATA as VERULINK_METADATA, SOURCE as VERULINK_SOURCE + +CHAINS.append({"id": "bsc", "displayName": "BNB Smart Chain", "family": "evm", "environment": "mainnet", + "nativeCurrencySymbol": "BNB", "protocolDomains": {"verulink": 422842677816}}) +ASSETS.append({"id": "bsc/aleo", "key": "aleo", "chainId": "bsc", "symbol": "ALEO", "name": "Verulink ALEO", + "decimals": 6, "kind": "token", "locator": {"kind": "evm-contract", "value": VERULINK_METADATA["evmToken"]}, + "addressValidationRegex": EVM_ADDRESS}) +for _src, _dst in (("aleo", "bsc"), ("bsc", "aleo")): + ROUTES.append(_route(f"verulink:{_src}/aleo->{_dst}/aleo", "verulink", "mainnet", + f"{_src}/aleo", f"{_dst}/aleo", "active", "verulink-aleo-bsc", + VERULINK_METADATA, VERULINK_SOURCE)) diff --git a/bridge-sdk/python/aleo_bridge/_verulink_abi.py b/bridge-sdk/python/aleo_bridge/_verulink_abi.py new file mode 100644 index 00000000..ceb1e48d --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_abi.py @@ -0,0 +1,48 @@ +"""Verulink service wire ABI (Veil PR 217, a2071c0b).""" +from typing import Any + + +def field(name: str, kind: str, components: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return {"name": name, "type": kind, **({"components": components} if components else {})} + + +def packet(outgoing: bool) -> list[dict[str, Any]]: + src, dst = ("address", "string") if outgoing else ("string", "address") + return [field("version", "uint256"), field("sequence", "uint256"), + field("sourceTokenService", "tuple", [field("chainId", "uint256"), field("addr", src)]), + field("destTokenService", "tuple", [field("chainId", "uint256"), field("addr", dst)]), + field("message", "tuple", [field("senderAddress", src), field("destTokenAddress", dst), + field("amount", "uint256"), field("receiverAddress", dst)]), + field("height", "uint256")] + + +def function(name: str, inputs: list[dict[str, Any]], outputs: list[dict[str, Any]], *, write: bool = False) -> dict[str, Any]: + return {"type": "function", "name": name, "stateMutability": "nonpayable" if write else "view", + "inputs": inputs, "outputs": outputs} + + +def event(name: str, inputs: list[dict[str, Any]], indexed: tuple[str, ...] = ()) -> dict[str, Any]: + return {"type": "event", "name": name, "anonymous": False, + "inputs": [{**x, "indexed": x["name"] in indexed} for x in inputs]} + + +ABI = [ + function("publicTokenSend", [field("tokenAddress", "address"), field("amount", "uint256"), field("receiver", "string")], [], write=True), + function("tokenReceive", [field("packet", "tuple", packet(False)), field("signatures", "bytes")], [], write=True), + function("self", [], [field("chainId", "uint256"), field("addr", "address")]), + function("supportedTokens", [field("token", "address")], [ + field("tokenAddress", "address"), field("vault", "address"), field("destTokenAddress", "string"), + field("destTokenService", "string"), field("minValue", "uint256"), field("maxValue", "uint256"), field("enabled", "bool")]), + function("platformFees", [field("token", "address")], [field("", "uint256")]), + function("balanceOf", [field("owner", "address")], [field("", "uint256")]), + function("allowance", [field("owner", "address"), field("spender", "address")], [field("", "uint256")]), + function("approve", [field("spender", "address"), field("amount", "uint256")], [field("", "bool")], write=True), + function("isAttestor", [field("signer", "address")], [field("", "bool")]), + function("consumedPackets", [field("sequence", "uint256")], [field("", "bytes32")]), + event("PacketDispatched", [field("packet", "tuple", packet(True)), field("data", "bytes")]), + event("Consumed", [field("chainId", "uint256"), field("sequence", "uint256"), field("packetHash", "bytes32"), field("quorum", "uint8")]), + event("Transfer", [field("from", "address"), field("to", "address"), field("value", "uint256")], ("from", "to")), +] +for _name, _type in (("paused", "bool"), ("destChainId", "uint256"), ("erc20Bridge", "address"), + ("holding", "address"), ("feeCollector", "address"), ("decimals", "uint8"), ("quorumRequired", "uint256")): + ABI.append(function(_name, [], [field("", _type)])) diff --git a/bridge-sdk/python/aleo_bridge/_verulink_attestation.py b/bridge-sdk/python/aleo_bridge/_verulink_attestation.py new file mode 100644 index 00000000..322c3a03 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_attestation.py @@ -0,0 +1,125 @@ +"""Verify current attestor membership and signed screening votes locally.""" +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import quote, urlsplit + +from .errors import BridgeError +from ._verulink_wire import (Packet, ZERO_ALEO, address, aleo_packet_literal, collector_packet, decimal, + integer, obj, packet_hash, same, same_packet) + + +def fetch_signatures(c: Any, session: Any, packet: Packet) -> list[dict[str, str]]: + base = urlsplit(c.collector_url) + if base.scheme != "https" or not base.netloc or base.username or base.password or base.query or base.fragment: + raise BridgeError("Verulink collector requires an HTTPS URL without credentials") + for page in range(1, 101): + url = f"{c.collector_url.rstrip('/')}/packet/wallet/{quote(packet['sender'], safe='')}?page={page}" + response = session.get(url, timeout=30) + if response.status_code != 200: + raise BridgeError(f"Verulink signature collector returned HTTP {response.status_code}") + body = obj(obj(response.json()).get("data")) + rows, pages = body.get("data"), body.get("totalPages") + if (type(body.get("currentPage")) is not int or body["currentPage"] != page + or type(pages) is not int or pages < 0 or not isinstance(rows, list) or len(rows) > 1000): + raise BridgeError("Invalid Verulink collector pagination") + for value in rows: + row = obj(value) + if (str(row.get("sourceChain")) != packet["sourceChain"] + or str(row.get("destinationChain")) != packet["destinationChain"] + or decimal(row.get("sequence")) != packet["sequence"]): + continue + if not same_packet(packet, collector_packet(row)): + raise BridgeError("Verulink collector packet does not match confirmed source data") + signatures = obj(row.get("status")).get("signatures") + if not isinstance(signatures, list) or len(signatures) > 100: + raise BridgeError("Invalid Verulink signature list") + return [signature(value) for value in signatures] + if page >= pages: + return [] + raise BridgeError("Verulink collector history exceeds the 100-page lookup bound") + + +def signature(value: Any) -> dict[str, str]: + value = obj(value) + if not isinstance(value.get("signer"), str) or not isinstance(value.get("sign"), str): + raise BridgeError("Invalid Verulink attestor signature") + return {"signer": value["signer"], "sign": value["sign"]} + + +def _aleo_votes(c: Any, packet: Packet, signatures: list[dict[str, str]]) -> list[bool]: + from importlib import import_module + sdk = import_module(f"aleo.{c.network}") + bhp = sdk.BHP256() + digest = bhp.hash(sdk.Plaintext.from_string(aleo_packet_literal(packet)).to_bits_le()) + messages = [str(bhp.hash(sdk.Plaintext.from_string( + f"{{ packet_hash: {digest}, screening_passed: {vote} }}").to_bits_le())) for vote in ("true", "false")] + result = [] + for value in signatures: + try: + addr = sdk.Address.from_string(value["signer"]) + sign = sdk.Signature.from_string(value["sign"]) + if sign.verify_value(addr, messages[0]): + result.append(True) + elif sign.verify_value(addr, messages[1]): + result.append(False) + else: + raise ValueError("unverified signature") + except ValueError as exc: + raise BridgeError("Invalid Verulink Aleo attestor signature") from exc + return result + + +def _evm_votes(packet: Packet, signatures: list[dict[str, str]]) -> list[bool]: + from eth_account import Account + from eth_account.messages import encode_defunct + from eth_utils.crypto import keccak + digest = packet_hash(packet) + result = [] + for value in signatures: + for vote in (1, 2): + try: + recovered = Account.recover_message(encode_defunct(keccak(digest + bytes([vote]))), signature=value["sign"]) + except (ValueError, TypeError) as exc: + raise BridgeError("Invalid Verulink EVM attestor signature") from exc + if same(recovered, value["signer"]): + result.append(vote == 1) + break + else: + raise BridgeError("Invalid Verulink EVM attestor signature") + return result + + +def verified_signatures(c: Any, packet: Packet, signatures: list[dict[str, str]]) -> list[dict[str, str]] | None: + threshold = integer(c.read(c.evm_bridge, "quorumRequired") if c.outbound else + c.mapping(c.aleo_bridge, "bridge_settings", "1u8"), "attestor quorum", 100 if c.outbound else 5) + if threshold < 1: + raise BridgeError("Verulink attestor quorum cannot be zero") + if len(signatures) < threshold: + return None + if len(signatures) > (100 if c.outbound else 5): + raise BridgeError("Verulink attestor array exceeds its bound") + seen = set() + for raw in signatures: + value = signature(raw) + signer, signed = value["signer"], value["sign"] + key = signer.lower() if c.outbound else signer + if key in seen or signer == ZERO_ALEO or signer.lower() == "0x" + "00" * 20: + raise BridgeError("Duplicate or zero Verulink attestor") + seen.add(key) + if c.outbound: + if not re.fullmatch(r"0x[0-9a-fA-F]{40}", signer) or not re.fullmatch(r"0x[0-9a-fA-F]{130}", signed): + raise BridgeError("Invalid EVM attestor signature encoding") + member = c.read(c.evm_bridge, "isAttestor", [address(signer)]) is True + else: + if not re.fullmatch(r"aleo1[0-9a-z]{58}", signer) or not re.fullmatch(r"sign1[0-9a-z]+", signed): + raise BridgeError("Invalid Aleo attestor signature encoding") + member = c.mapping(c.aleo_bridge, "attestors", signer) is not None + if not member: + raise BridgeError("Unknown Verulink attestor") + votes = _evm_votes(packet, signatures) if c.outbound else _aleo_votes(c, packet, signatures) + positive = [value for value, vote in zip(signatures, votes) if vote] + if len(signatures) - len(positive) >= threshold: + raise BridgeError("Verulink attestors screened this packet negatively; public delivery is unavailable") + return sorted(positive, key=lambda x: x["signer"].lower()) if len(positive) >= threshold else None diff --git a/bridge-sdk/python/aleo_bridge/_verulink_context.py b/bridge-sdk/python/aleo_bridge/_verulink_context.py new file mode 100644 index 00000000..05f373cd --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_context.py @@ -0,0 +1,126 @@ +"""Resolve reviewed deployments and read fresh Verulink contract state.""" +from __future__ import annotations + +import re +from typing import Any + +from ._verulink_abi import ABI +from ._verulink_wire import ZERO_ALEO, U64_MAX, address, decimal, from_aleo_bytes, integer, parse, same +from .errors import BridgeError +from .types import Plan +from .units import parse_decimal_amount + + +class Context: + def __init__(self, bridge: Any, plan: Plan) -> None: + from .lifecycle import resolve_route + r = resolve_route(bridge.registry, plan) + self.bridge, self.aleo, self.network = bridge, bridge.aleo, bridge.environment + self.route, self.source, self.destination = r.route, r.source_asset, r.destination_asset + self.outbound = r.source_chain.family == "aleo" + aleo_chain, evm_chain = (r.source_chain, r.destination_chain) if self.outbound else (r.destination_chain, r.source_chain) + if (plan.protocol != "verulink" or not r.route.active or plan.environment != bridge.environment + or aleo_chain.family != "aleo" or evm_chain.family != "evm" + or plan.mint_mode != "public" or plan.cctp is not None + or self.source.decimals != 6 or self.destination.decimals != 6): + raise BridgeError("Verulink requires an active public ALEO route between Aleo and EVM") + self.aleo_chain, self.evm_chain = aleo_chain.id, evm_chain.id + self.evm = bridge.evm(evm_chain.id).conn + self.evm_chain_id = r.route.meta_int("evmChainId") + self.confirmations = r.route.meta_int("sourceConfirmations") + if self.evm_chain_id < 1 or self.confirmations < 1: + raise BridgeError("Invalid Verulink chain id or confirmation policy") + self.evm_domain = decimal(r.route.meta_str("evmDomain"), 2**128 - 1) + self.aleo_domain = decimal(r.route.meta_str("aleoDomain"), 2**128 - 1) + for chain, domain in ((aleo_chain, self.aleo_domain), (evm_chain, self.evm_domain)): + if domain == "0" or ("verulink" in chain.protocol_domains and chain.protocol_domains["verulink"] != int(domain)): + raise BridgeError("Verulink domain does not match chain metadata") + self.evm_token = address(r.route.meta_str("evmToken")) + self.evm_service = address(r.route.meta_str("evmTokenService")) + self.evm_bridge = address(r.route.meta_str("evmBridge")) + self.aleo_service = r.route.meta_str("aleoTokenService") + self.aleo_bridge = r.route.meta_str("aleoBridge") + self.aleo_service_address = r.route.meta_str("aleoServiceAddress") + self.aleo_token_id = r.route.meta_str("aleoTokenId") + self.collector_url = r.route.meta_str("collectorUrl") + if (any(not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_]*\.aleo", p) for p in (self.aleo_service, self.aleo_bridge)) + or not re.fullmatch(r"aleo1[0-9a-z]{58}", self.aleo_service_address) + or not re.fullmatch(r"[0-9]+field", self.aleo_token_id)): + raise BridgeError("Invalid Verulink Aleo deployment") + aa, ea = (self.source, self.destination) if self.outbound else (self.destination, self.source) + if (aa.locator is None or aa.locator.value != "credits.aleo" or aa.locator.kind != "aleo-program" + or ea.locator is None or ea.locator.kind != "evm-contract" or not same(ea.locator.value, self.evm_token)): + raise BridgeError("Verulink asset does not match the reviewed token") + if self.outbound: + address(plan.recipient) + else: + self._aleo_address(plan.recipient) + if plan.recipient == ZERO_ALEO or same(plan.recipient, "0x" + "00" * 20): + raise BridgeError("Verulink recipient cannot be zero") + if plan.sender is not None: + self._aleo_address(plan.sender) if self.outbound else address(plan.sender) + if (integer(plan.amount_atomic, "amount", U64_MAX) < 1 + or parse_decimal_amount(plan.amount, 6) != plan.amount_atomic): + raise BridgeError("Verulink amount must be a matching positive u64") + if plan.amount_out is not None and not 0 < parse_decimal_amount(plan.amount_out, 6) <= plan.amount_atomic: + raise BridgeError("Invalid Verulink quoted output") + self.chain_token = f"{{ chain_id: {self.evm_domain}u128, token_id: {self.aleo_token_id} }}" + + @staticmethod + def _aleo_address(value: str) -> None: + from aleo import mainnet + try: + mainnet.Address.from_string(value) + except ValueError as exc: + raise BridgeError("Invalid Verulink Aleo address") from exc + + def check_chain(self) -> None: + # Check the provider each pass, not Ethereum.chain_id's cached value. + if self.evm.w3.eth.chain_id != self.evm_chain_id: + raise BridgeError(f"Verulink requires EVM chain {self.evm_chain_id}") + + def contract(self, to: str) -> Any: + return self.evm.w3.eth.contract(address=address(to), abi=ABI) + + def read(self, to: str, name: str, args: list[Any] | None = None) -> Any: + return self.contract(to).get_function_by_name(name)(*(args or [])).call() + + def mapping(self, program: str, name: str, key: str) -> Any: + value = self.aleo.network.get_program_mapping_value(program, name, key) + return None if value is None else parse(value) + + def assert_active(self, *, destination_only: bool = False) -> None: + self.check_chain() + if (not destination_only or self.outbound) and ( + self.read(self.evm_service, "paused") is not False + or self.read(self.evm_bridge, "paused") is not False): + raise BridgeError("Verulink route is paused, unsupported, or unreadable") + if not destination_only or not self.outbound: + active = self.mapping(self.aleo_bridge, "bridge_settings", "3u8") + if (self.mapping(self.aleo_service, "status", self.chain_token) is not False + or type(active) is not int or active != 1 + or self.mapping(self.aleo_bridge, "supported_chains", self.evm_domain + "u128") is not True + or self.mapping(self.aleo_bridge, "supported_services", self.aleo_service_address) is not True): + raise BridgeError("Verulink route is paused, unsupported, or unreadable") + + def configuration(self) -> tuple[int, int, int]: + self.check_chain() + own = self.read(self.evm_service, "self") + token = self.read(self.evm_service, "supportedTokens", [self.evm_token]) + if (not isinstance(own, (list, tuple)) or len(own) != 2 + or own[0] != int(self.evm_domain) or not same(own[1], self.evm_service) + or self.read(self.evm_service, "destChainId") != int(self.aleo_domain) + or not same(self.read(self.evm_service, "erc20Bridge"), self.evm_bridge) + or not isinstance(token, (list, tuple)) or len(token) != 7 or not same(token[0], self.evm_token) + or token[2] != self.aleo_token_id or token[3] != self.aleo_service_address or token[6] is not True + or self.read(self.evm_token, "decimals") != 6 + or from_aleo_bytes(self.mapping(self.aleo_service, "other_chain_token_service", self.chain_token)) != self.evm_service + or from_aleo_bytes(self.mapping(self.aleo_service, "other_chain_token_address", self.chain_token)) != self.evm_token): + raise BridgeError("Verulink onchain peers do not match the reviewed deployment") + if self.outbound: + rate, minimum, maximum = [self.mapping(self.aleo_service, key, self.chain_token) + for key in ("platform_fee", "min_transfers", "max_transfers")] + else: + collector = address(self.read(self.evm_service, "feeCollector")) + rate, minimum, maximum = self.read(collector, "platformFees", [self.evm_token]), token[4], token[5] + return integer(rate, "fee rate", 100_000), integer(minimum, "minimum"), integer(maximum, "maximum") diff --git a/bridge-sdk/python/aleo_bridge/_verulink_deployment.py b/bridge-sdk/python/aleo_bridge/_verulink_deployment.py new file mode 100644 index 00000000..aa14bb0c --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_deployment.py @@ -0,0 +1,21 @@ +"""Reviewed Verulink mainnet deployment, ported from Veil PR 217 at a2071c0b. + +_source: venture23-aleo/verulink @ 4307bfbdf414a84205ec426d5bddc1dff0a2ebfc. +""" + +SOURCE = "https://github.com/venture23-aleo/verulink/tree/4307bfbdf414a84205ec426d5bddc1dff0a2ebfc" +METADATA = { + "registryCommit": "4307bfbdf414a84205ec426d5bddc1dff0a2ebfc", + "evmChainId": 56, + "evmDomain": "422842677816", + "aleoDomain": "6694886634401", + "evmTokenService": "0x4752397298793a7db886bEfC6c8E38729D269744", + "evmBridge": "0x397e47F5072B48681b170199551bdB7fBDa136b7", + "evmToken": "0x6CFfFa5bFD4277a04D83307fEedFe2D18D944DD2", + "aleoTokenService": "vlink_token_service_cd_v3.aleo", + "aleoServiceAddress": "aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq", + "aleoBridge": "vlink_token_bridge_v3.aleo", + "aleoTokenId": "3443843282313283355522573239085696902919850365217539366784739393210722344986field", + "collectorUrl": "https://alb-backend-mainnet.verulink.com/v2", + "sourceConfirmations": 15, +} diff --git a/bridge-sdk/python/aleo_bridge/_verulink_source.py b/bridge-sdk/python/aleo_bridge/_verulink_source.py new file mode 100644 index 00000000..808a6f88 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_source.py @@ -0,0 +1,219 @@ +"""Reconstruct Verulink packets from immutable, confirmed source evidence.""" +from __future__ import annotations + +import json +import re +from typing import Any + +from ._verulink_context import Context +from ._verulink_wire import (Packet, aleo_packet, decimal, evm_packet, from_aleo_bytes, + integer, obj, parse, same) +from .errors import BridgeError +from .types import Plan +from .units import parse_decimal_amount + + +def tx_hash(value: Any) -> str: + if not isinstance(value, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", value): + raise BridgeError("Invalid Verulink EVM transaction id") + return value + + +def hex_value(value: Any) -> str: + from web3 import Web3 + return value if isinstance(value, str) else Web3.to_hex(value) + + +def transition(tx: Any, program: str, name: str) -> dict[str, Any]: + calls = [t for t in obj(obj(tx).get("execution")).get("transitions", []) + if obj(t).get("program") == program and t.get("function") == name] + if len(calls) != 1: + raise BridgeError(f"Verulink transaction requires exactly one {program}/{name} transition") + return obj(calls[0]) + + +def inputs(call: dict[str, Any]) -> list[Any]: + values = call.get("inputs") + if not isinstance(values, list): + raise BridgeError("Verulink transition has missing public inputs") + result = [] + for raw in values: + item = obj(raw) + if item.get("type") != "public" or not isinstance(item.get("value"), str): + raise BridgeError("Verulink transition has missing public inputs") + result.append(parse(item["value"])) + return result + + +def _root(tx: Any, program: str, name: str) -> None: + transitions = obj(obj(tx).get("execution")).get("transitions") + if (not isinstance(transitions, list) or not transitions or obj(transitions[-1]).get("program") != program + or transitions[-1].get("function") != name): + raise BridgeError("Verulink root call does not match the transfer") + + +def prepared_transaction(serialized: Any, tx_id: Any) -> dict[str, Any]: + if not isinstance(tx_id, str) or not tx_id or not isinstance(serialized, str): + raise BridgeError("Invalid Verulink prepared transaction") + try: + tx = obj(json.loads(serialized)) + except (ValueError, TypeError) as exc: + raise BridgeError("Invalid Verulink prepared transaction") from exc + if tx.get("id") != tx_id: + raise BridgeError("Verulink prepared transaction id mismatch") + return tx + + +def aleo_transaction(c: Context, tx_id: str) -> dict[str, Any] | str: + from aleo.facade.errors import TransactionNotFound + try: + confirmed = obj(c.aleo.network.get_confirmed_transaction(tx_id)) + except TransactionNotFound: + return "pending" + if confirmed.get("status") == "rejected": + return "rejected" + if confirmed.get("status") != "accepted": + return "pending" + tx = obj(confirmed.get("transaction")) + if tx.get("id") != tx_id: + raise BridgeError("Verulink Aleo transaction identity mismatch") + return tx + + +def validate_aleo_source(c: Context, plan: Plan, tx: Any) -> tuple[int, str]: + _root(tx, c.aleo_service, "token_send_public") + call = inputs(transition(tx, c.aleo_service, "token_send_public")) + if (len(call) != 6 or not same(from_aleo_bytes(call[0]), plan.recipient) + or type(call[1]) is not int or call[1] != plan.amount_atomic or call[2] != int(c.evm_domain) + or from_aleo_bytes(call[3]) != c.evm_service or from_aleo_bytes(call[4]) != c.evm_token): + raise BridgeError("Verulink Aleo source inputs do not match the transfer") + amount = integer(call[1], "submitted amount") - integer(call[5], "submitted fee") + published = inputs(transition(tx, c.aleo_bridge, "publish")) + if (len(published) != 7 or published[0] != 1 or published[1] != int(c.evm_domain) + or from_aleo_bytes(published[2]) != c.evm_service or from_aleo_bytes(published[3]) != c.evm_token + or not same(from_aleo_bytes(published[5]), plan.recipient) or published[6] != amount + or not isinstance(published[4], str) or (plan.sender is not None and published[4] != plan.sender)): + raise BridgeError("Verulink published packet does not match the source inputs") + if amount <= 0: + raise BridgeError("Verulink source net amount must be positive") + if plan.amount_out is not None and amount < parse_decimal_amount(plan.amount_out, 6): + raise BridgeError("Verulink prepared source fee exceeds the quoted fee") + return amount, published[4] + + +def validate_aleo_claim(c: Context, packet: Packet, tx: Any) -> dict[str, Any]: + _root(tx, c.aleo_service, "token_receive_public") + claim = transition(tx, c.aleo_service, "token_receive_public") + v = inputs(claim) + if (len(v) != 9 or not same(from_aleo_bytes(v[0]), packet["sender"]) or v[1] != packet["recipient"] + or v[2] != int(packet["amount"]) or v[3] != int(packet["sequence"]) or v[4] != int(packet["height"]) + or v[7] != int(packet["sourceChain"]) or not same(from_aleo_bytes(v[8]), packet["sourceAddress"])): + raise BridgeError("Verulink Aleo claim does not match the source packet") + return claim + + +def validate_packet(c: Context, plan: Plan, p: Packet) -> None: + if (p["version"] != "1" or int(p["sequence"]) < 1 + or p["sourceChain"] != (c.aleo_domain if c.outbound else c.evm_domain) + or p["destinationChain"] != (c.evm_domain if c.outbound else c.aleo_domain) + or not same(p["sourceAddress"], c.aleo_service_address if c.outbound else c.evm_service) + or not same(p["destinationAddress"], c.evm_service if c.outbound else c.aleo_service_address) + or not same(p["token"], c.evm_token if c.outbound else c.aleo_token_id) + or not same(p["recipient"], plan.recipient) or (plan.sender is not None and not same(p["sender"], plan.sender)) + or not 0 < int(p["amount"]) <= plan.amount_atomic): + raise BridgeError("Verulink source packet does not match the transfer") + + +def events(c: Context, result: Any, to: str, name: str) -> list[dict[str, Any]]: + from web3.exceptions import MismatchedABI + decoder = getattr(c.contract(to).events, name)() + decoded = [] + for log in result.get("logs", []): + if not same(log.get("address"), to): + continue + try: + decoded.append(dict(decoder.process_log(log)["args"])) + except (MismatchedABI, ValueError, KeyError, TypeError): + continue + return decoded + + +def get_transaction(c: Context, tx_id: str) -> Any: + from web3.exceptions import TransactionNotFound + try: + return c.evm.w3.eth.get_transaction(tx_id) + except TransactionNotFound: + return None + + +def source_packet(c: Context, plan: Plan, tx_id: str) -> Packet | str: + c.check_chain() + if not c.outbound: + tx_hash(tx_id) + result = c.evm.get_receipt(tx_id) + if result is None: + return "pending" + if not same(hex_value(result.get("transactionHash")), tx_id) or result.get("blockNumber") is None: + raise BridgeError("Verulink source receipt identity mismatch") + if result.get("status") == 0: + return "rejected" + if result.get("status") != 1: + raise BridgeError("Invalid Verulink source receipt status") + if c.evm.w3.eth.block_number < result["blockNumber"] + c.confirmations - 1: + return "pending" + tx = get_transaction(c, tx_id) + if (tx is None or not same(hex_value(tx.get("hash")), tx_id) or not same(tx.get("to"), c.evm_service) + or tx.get("blockNumber") != result["blockNumber"]): + raise BridgeError("Verulink source transaction does not match its receipt") + fn, args = c.contract(c.evm_service).decode_function_input(tx["input"]) + if (fn.fn_name != "publicTokenSend" or not same(args["tokenAddress"], c.evm_token) + or args["amount"] != plan.amount_atomic or args["receiver"] != plan.recipient + or (plan.sender is not None and not same(tx.get("from"), plan.sender))): + raise BridgeError("Verulink source calldata does not match the transfer") + packets = [evm_packet(e["packet"]) for e in events(c, result, c.evm_bridge, "PacketDispatched")] + if len(packets) != 1: + raise BridgeError("Verulink source receipt is missing its unique PacketDispatched event") + p = packets[0] + validate_packet(c, plan, p) + if not same(p["sender"], tx["from"]) or p["height"] != str(result["blockNumber"]): + raise BridgeError("Verulink packet does not match its source transaction") + return p + tx = aleo_transaction(c, tx_id) + if isinstance(tx, str): + return tx + amount, sender = validate_aleo_source(c, plan, tx) + # The facade has no transaction-to-block lookup; reuse its configured node transport. + block_hash = c.aleo._client._get(f"/find/blockHash/{tx_id}", "findBlockHash") + block = obj(c.aleo.network.get_block_by_hash(block_hash)) + height = decimal(obj(obj(block.get("header")).get("metadata")).get("height")) + next_sequence = integer(c.mapping(c.aleo_bridge, "sequences", c.evm_domain + "u128"), "next sequence") + + def read(sequence: int) -> Packet: + p = aleo_packet(c.mapping(c.aleo_bridge, "out_packets", f"{{ chain_id: {c.evm_domain}u128, sequence: {sequence}u64 }}")) + if p["sequence"] != str(sequence): + raise BridgeError("Verulink packet sequence does not match its mapping key") + return p + + low, high = 1, next_sequence + while low < high: + middle = (low + high) // 2 + if int(read(middle)["height"]) < int(height): + low = middle + 1 + else: + high = middle + matches = [] + for count, sequence in enumerate(range(low, next_sequence)): + if count >= 128: + raise BridgeError("Verulink source block packet scan exceeds its bound") + p = read(sequence) + if p["height"] != height: + break + if (same(p["sender"], sender) and same(p["recipient"], plan.recipient) and p["amount"] == str(amount) + and same(p["sourceAddress"], c.aleo_service_address) and same(p["destinationAddress"], c.evm_service) + and same(p["token"], c.evm_token)): + matches.append(p) + if len(matches) != 1: + raise BridgeError("Ambiguous Verulink packets in the Aleo source block" if matches else + "Verulink source packet does not match the confirmed transaction") + validate_packet(c, plan, matches[0]) + return matches[0] diff --git a/bridge-sdk/python/aleo_bridge/_verulink_status.py b/bridge-sdk/python/aleo_bridge/_verulink_status.py new file mode 100644 index 00000000..5e689da9 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_status.py @@ -0,0 +1,148 @@ +"""Observe Verulink delivery without treating screening holding as success.""" +from __future__ import annotations + +from typing import Any + +from ._verulink_attestation import fetch_signatures, verified_signatures +from ._verulink_context import Context +from ._verulink_source import (aleo_transaction, events, get_transaction, hex_value, inputs, prepared_transaction, + source_packet, transition, tx_hash, validate_aleo_claim) +from ._verulink_wire import Packet, evm_packet, packet_hash, same, same_packet +from .errors import BridgeError +from .types import Plan, Receipt, Status + + +def update(receipt: Receipt, status: Status, **state: Any) -> Receipt: + return receipt.replace(status=status, next_action=None, protocol_state={**receipt.protocol_state, **state}) + + +def consumed(c: Context, p: Packet) -> bool: + if c.outbound: + value = c.read(c.evm_bridge, "consumedPackets", [int(p["sequence"])]) + if value == bytes(32): + return False + if value != packet_hash(p): + raise BridgeError("Verulink sequence was consumed with a different packet hash") + return True + value = c.mapping(c.aleo_bridge, "in_packet_consumed", f"{{ chain_id: {p['sourceChain']}u128, sequence: {p['sequence']}u64 }}") + if value is True: + return True + if value is False or value is None: + return False + raise BridgeError("Invalid Verulink consumed-packet flag") + + +def destination_status(c: Context, p: Packet, receipt: Receipt) -> Receipt: + tx_id = receipt.destination_tx_id + if not tx_id: + raise BridgeError("Verulink claim transaction id is missing") + if c.outbound: + tx_hash(tx_id) + result = c.evm.get_receipt(tx_id) + if result is None: + return update(receipt, Status.DESTINATION_CONFIRMING) + if not same(hex_value(result.get("transactionHash")), tx_id) or result.get("blockNumber") is None: + raise BridgeError("Verulink claim receipt identity mismatch") + if result.get("status") == 0: + return update(receipt, Status.FAILED, destinationError=f"Verulink claim reverted: {tx_id}") + if result.get("status") != 1: + raise BridgeError("Invalid Verulink claim receipt status") + if c.evm.w3.eth.block_number < result["blockNumber"] + c.confirmations - 1: + return update(receipt, Status.DESTINATION_CONFIRMING) + tx = get_transaction(c, tx_id) + if (tx is None or not same(hex_value(tx.get("hash")), tx_id) or not same(tx.get("to"), c.evm_service) + or tx.get("blockNumber") != result["blockNumber"]): + raise BridgeError("Verulink claim transaction identity mismatch") + fn, args = c.contract(c.evm_service).decode_function_input(tx["input"]) + if fn.fn_name != "tokenReceive" or not same_packet(p, evm_packet(args["packet"])): + raise BridgeError("Verulink claim calldata does not match the source packet") + consume_event = any(e["chainId"] == int(p["sourceChain"]) and e["sequence"] == int(p["sequence"]) + and e["packetHash"] == packet_hash(p) for e in events(c, result, c.evm_bridge, "Consumed")) + if not consume_event or not consumed(c, p): + raise BridgeError("Verulink claim receipt lacks the expected consumption evidence") + mints = [e for e in events(c, result, c.evm_token, "Transfer") + if same(e["from"], "0x" + "00" * 20) and e["value"] == int(p["amount"])] + if any(same(e["to"], p["recipient"]) for e in mints): + return update(receipt, Status.COMPLETED) + if mints and any(same(e["to"], c.read(c.evm_service, "holding")) for e in mints): + return update(receipt, Status.FAILED, destinationError="Verulink screening placed the transferred ALEO in holding", held=True) + raise BridgeError("Verulink claim receipt does not prove recipient delivery") + tx = aleo_transaction(c, tx_id) + if tx == "pending": + return update(receipt, Status.DESTINATION_CONFIRMING) + if tx == "rejected": + return update(receipt, Status.FAILED, destinationError=f"Verulink Aleo claim rejected: {tx_id}") + claim = validate_aleo_claim(c, p, tx) + if not consumed(c, p): + raise BridgeError("Accepted Verulink claim has no consumed-packet flag") + screening = next((o.get("value") for o in claim.get("outputs", []) if o.get("type") == "public"), None) + if screening == "false": + return update(receipt, Status.FAILED, destinationError="Verulink screening placed the transferred ALEO in holding", held=True) + if screening != "true": + raise BridgeError("Verulink Aleo claim has no screening result") + transfer = inputs(transition(tx, "credits.aleo", "transfer_public")) + if len(transfer) != 2 or transfer[0] != p["recipient"] or transfer[1] != int(p["amount"]): + raise BridgeError("Verulink Aleo claim does not prove recipient delivery") + return update(receipt, Status.COMPLETED) + + +def approval_status(c: Context, plan: Plan, receipt: Receipt) -> Receipt: + ids = receipt.protocol_state.get("approvalTxIds") + if not isinstance(ids, list) or not ids: + raise BridgeError("Invalid Verulink approval checkpoint") + for tx_id in ids: + tx_hash(tx_id) + result, tx = c.evm.get_receipt(tx_id), get_transaction(c, tx_id) + if result is None or tx is None: + return update(receipt, Status.SOURCE_APPROVAL_PENDING) + if (not same(hex_value(result.get("transactionHash")), tx_id) + or not same(hex_value(tx.get("hash")), tx_id) or not same(tx.get("to"), c.evm_token) + or not same(tx.get("from"), plan.sender or receipt.protocol_state.get("sourceSender")) + or result.get("blockNumber") is None or tx.get("blockNumber") != result["blockNumber"]): + raise BridgeError("Verulink approval does not match the transfer") + fn, args = c.contract(c.evm_token).decode_function_input(tx["input"]) + if fn.fn_name != "approve" or not same(args["spender"], c.evm_service) or args["amount"] != plan.amount_atomic: + raise BridgeError("Verulink approval does not match the transfer") + if result.get("status") == 0: + return update(receipt, Status.FAILED, sourceError=f"Verulink approval reverted: {tx_id}") + if result.get("status") != 1: + raise BridgeError("Invalid Verulink approval status") + return update(receipt, Status.SOURCE_SUBMISSION_PENDING) + + +def get_status(c: Context, plan: Plan, receipt: Receipt, session: Any) -> Receipt: + if receipt.protocol != "verulink" or receipt.protocol_state.get("routeId") != c.route.id: + raise BridgeError("Verulink receipt does not match the route") + c.check_chain() + if not receipt.source_tx_id: + if not c.outbound and receipt.status in (Status.SOURCE_APPROVAL_PENDING, Status.SOURCE_SUBMISSION_PENDING): + return approval_status(c, plan, receipt) + raise BridgeError("Verulink receipt has no source transaction") + p = source_packet(c, plan, receipt.source_tx_id) + if p == "pending": + return update(receipt, Status.SOURCE_CONFIRMING) + if p == "rejected": + return update(receipt, Status.FAILED, sourceError="Verulink source transaction was rejected") + assert isinstance(p, dict) + verified = update(receipt, Status.ATTESTATION_PENDING, packet=p, sourceSender=p["sender"]) + if receipt.destination_tx_id: + return destination_status(c, p, verified) + serialized = receipt.protocol_state.get("preparedDestinationTransaction") + if serialized is not None: + if c.outbound or not isinstance(serialized, str): + raise BridgeError("Invalid Verulink prepared destination transaction") + tx = prepared_transaction(serialized, receipt.id) + validate_aleo_claim(c, p, tx) + if aleo_transaction(c, tx["id"]) != "pending": + state = dict(verified.protocol_state) + state.pop("preparedDestinationTransaction", None) + return destination_status(c, p, verified.replace(destination_tx_id=tx["id"], protocol_state=state)) + if consumed(c, p): + return update(verified, Status.DELIVERY_PENDING, consumed=True, + destinationVerificationRequired="Packet consumed; a destination transaction id is required to distinguish public delivery from holding") + signatures = verified_signatures(c, p, fetch_signatures(c, session, p)) + if signatures is None: + return verified + return verified.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "verulink-claim", "chainId": c.destination.chain_id}, + protocol_state={**verified.protocol_state, "signatures": signatures}) diff --git a/bridge-sdk/python/aleo_bridge/_verulink_wire.py b/bridge-sdk/python/aleo_bridge/_verulink_wire.py new file mode 100644 index 00000000..8a56059e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_verulink_wire.py @@ -0,0 +1,123 @@ +"""Canonical public packet codecs and transaction checks for Verulink.""" +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from aleo.codegen.runtime import parse_plaintext + +from .errors import BridgeError + +U64_MAX = 2**64 - 1 +ZERO_ALEO = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +Packet = dict[str, str] + + +def integer(value: Any, name: str = "integer", maximum: int = U64_MAX) -> int: + if type(value) is not int or not 0 <= value <= maximum: + raise BridgeError(f"Invalid Verulink {name}") + return value + + +def decimal(value: Any, maximum: int = U64_MAX) -> str: + if isinstance(value, str) and re.fullmatch(r"[0-9]+", value): + value = int(value) + return str(integer(value, "integer encoding", maximum)) + + +def obj(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise BridgeError("Invalid Verulink object") + return dict(value) + + +def same(a: Any, b: Any) -> bool: + if not isinstance(a, str) or not isinstance(b, str): + return False + return a.lower() == b.lower() if a.startswith("0x") and b.startswith("0x") else a == b + + +def same_packet(a: Packet, b: Packet) -> bool: + return a.keys() == b.keys() and all(same(a[key], b[key]) for key in a) + + +def address(value: Any) -> str: + from web3 import Web3 + if not isinstance(value, str) or not re.fullmatch(r"0x[0-9a-fA-F]{40}", value): + raise BridgeError("Invalid Verulink EVM address") + return Web3.to_checksum_address(value) + + +def address_literal(value: str, padded: bool = False) -> str: + raw = bytes.fromhex(address(value)[2:]) + return "[" + ", ".join(f"{b}u8" for b in ((b"\0" * 12 if padded else b"") + raw)) + "]" + + +def from_aleo_bytes(value: Any) -> str: + if (not isinstance(value, list) or len(value) not in (20, 32) + or any(type(x) is not int or not 0 <= x <= 255 for x in value) + or any(value[:-20])): + raise BridgeError("Invalid Verulink address bytes") + return address("0x" + bytes(value[-20:]).hex()) + + +def collector_packet(value: Any) -> Packet: + p = obj(value) + m = obj(p.get("message")) + return {"version": decimal(p.get("version")), "sequence": decimal(p.get("sequence")), + "height": decimal(p.get("height")), "sourceChain": decimal(p.get("sourceChain"), 2**128 - 1), + "destinationChain": decimal(p.get("destinationChain"), 2**128 - 1), + "sourceAddress": str(p.get("sourceAddress")), "destinationAddress": str(p.get("destinationAddress")), + "sender": str(m.get("sender")), "recipient": str(m.get("receiver")), + "token": str(m.get("denom")), "amount": decimal(m.get("amount"))} + + +def aleo_packet(value: Any) -> Packet: + p = obj(value) + source, dest, m = obj(p.get("source")), obj(p.get("destination")), obj(p.get("message")) + return {"version": decimal(p.get("version")), "sequence": decimal(p.get("sequence")), "height": decimal(p.get("height")), + "sourceChain": decimal(source.get("chain_id"), 2**128 - 1), "destinationChain": decimal(dest.get("chain_id"), 2**128 - 1), + "sourceAddress": str(source.get("addr")), "destinationAddress": from_aleo_bytes(dest.get("addr")), + "sender": str(m.get("sender_address")), "recipient": from_aleo_bytes(m.get("receiver_address")), + "token": from_aleo_bytes(m.get("dest_token_address")), "amount": decimal(m.get("amount"))} + + +def evm_packet(value: Any) -> Packet: + p = obj(value) + src, dst, m = obj(p.get("sourceTokenService")), obj(p.get("destTokenService")), obj(p.get("message")) + return {"version": decimal(p.get("version")), "sequence": decimal(p.get("sequence")), "height": decimal(p.get("height")), + "sourceChain": decimal(src.get("chainId"), 2**128 - 1), "destinationChain": decimal(dst.get("chainId"), 2**128 - 1), + "sourceAddress": str(src.get("addr")), "destinationAddress": str(dst.get("addr")), + "sender": str(m.get("senderAddress")), "recipient": str(m.get("receiverAddress")), + "token": str(m.get("destTokenAddress")), "amount": decimal(m.get("amount"))} + + +def incoming(p: Packet) -> tuple: + return (int(p["version"]), int(p["sequence"]), (int(p["sourceChain"]), p["sourceAddress"]), + (int(p["destinationChain"]), address(p["destinationAddress"])), + (p["sender"], address(p["token"]), int(p["amount"]), address(p["recipient"])), int(p["height"])) + + +def packet_hash(p: Packet) -> bytes: + from eth_abi.packed import encode_packed + from eth_utils.crypto import keccak + return keccak(encode_packed( + ["uint256", "uint256", "uint256", "string", "uint256", "address", "string", "address", "uint256", "address", "uint256"], + [int(p["version"]), int(p["sequence"]), int(p["sourceChain"]), p["sourceAddress"], int(p["destinationChain"]), + p["destinationAddress"], p["sender"], p["token"], int(p["amount"]), p["recipient"], int(p["height"])])) + + +def aleo_packet_literal(p: Packet) -> str: + return (f"{{ version: {p['version']}u8, sequence: {p['sequence']}u64, " + f"source: {{ chain_id: {p['sourceChain']}u128, addr: {address_literal(p['sourceAddress'], True)} }}, " + f"destination: {{ chain_id: {p['destinationChain']}u128, addr: {p['destinationAddress']} }}, " + f"message: {{ sender_address: {address_literal(p['sender'], True)}, dest_token_id: {p['token']}, " + f"amount: {p['amount']}u128, receiver_address: {p['recipient']} }}, height: {p['height']}u64 }}") + + +def parse(value: Any) -> Any: + try: + return parse_plaintext(value) + except (TypeError, ValueError) as exc: + raise BridgeError("Invalid Verulink Aleo plaintext") from exc diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index 01c8bc3a..48d36f10 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -40,7 +40,7 @@ from .checkpoint import Checkpoint, create_checkpoint from .errors import BridgeError, ConfigurationError, PauseCheckError, RoutePausedError from .registry import DEFAULT_REGISTRY, Registry -from .types import EvmXReserveQuote, Receipt +from .types import EvmXReserveQuote, Plan, Receipt from .units import format_decimal_amount _S = {"type": "string"} @@ -101,6 +101,8 @@ def _serialize(value: Any, registry: Registry | None = None) -> Any: """ if isinstance(value, Checkpoint): return value.to_dict() + if isinstance(value, Plan): + return _serialize(value.to_dict(), registry) if isinstance(value, Receipt): return {f.name: (_redacted_state(getattr(value, f.name)) if f.name == "protocol_state" else _serialize(getattr(value, f.name), registry)) @@ -191,6 +193,15 @@ def _connection_gap(bridge: Any, source_chain: Any, *, signing: bool = False) -> family = chain.family except (BridgeError, TypeError): return None # let the real lookup produce the real error + if family == "evm" and chain.id not in ("ethereum", "sepolia"): + try: + connection = bridge.evm(chain.id).conn + if signing and not connection.can_sign: + return _error_payload(ConfigurationError(f"The {chain.id} connection is read-only; a signer is required."), + how_to_fix=f"set EVM_PRIVATE_KEY + {chain.id.upper()}_RPC_URL and retry") + return None + except ConfigurationError as exc: + return _error_payload(exc) ethereum = getattr(bridge, "ethereum", None) if family == "evm" and ethereum is None: return _error_payload(ConfigurationError( @@ -225,7 +236,7 @@ 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", "cctp"], "description": "Only needed when multiple protocols serve the pair."}, + "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane", "cctp", "verulink"], "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."}}}, @@ -428,7 +439,7 @@ 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 progress.plan.protocol != "cctp" and not secret_nonce and not _has_prepared_destination(progress): + if progress.plan.protocol == "xreserve" 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)) @@ -467,19 +478,21 @@ def _h_unshield(b, a): "(aleo, ethereum, solana, aleo-testnet, sepolia) or symbol.", _schema({"chain": _S, "symbol": _S, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_assets), ("bridge_list_routes", - "Supported directions and their protocol (xreserve = USDC<->USDCx via Circle; hyperlane = ETH/WBTC/USDT/SOL/BAT/USDG/ZEC). " + "Supported directions and their protocol (xreserve = USDC<->USDCx; hyperlane = ETH/WBTC/USDT/SOL/BAT/USDG/ZEC; " + "cctp = native USDC; verulink = public ALEO between Aleo and BSC with a destination claim). " "Active routes move funds; metadata-required ones are listed but refused by quote/execute.", _schema({"source_chain": _S, "source_asset": _S, "destination_chain": _S, "destination_asset": _S, - "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane"]}, "symbol": _S, + "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane", "cctp", "verulink"]}, "symbol": _S, "include_unavailable": _B, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_routes), ("bridge_pause_status", "Read the operator pause switches on both ends of a route, live (reads only, never cached): the Aleo warp/USDCx " "program pauses and enrolled routers, paused() on xReserve and the CCTP transmitter, any pausable hook/ISM in the " - "Ethereum Mailbox, and the Solana warp program's enrolled router. Name the route by route id or by " + "Ethereum Mailbox, the Solana warp program's enrolled router, and Verulink's Aleo settings and BSC pauses. " + "Name the route by route id or by " "source_chain + source_asset + destination_chain. bridge_quote/bridge_execute run this same check and refuse a " "paused or unreadable route.", _schema({"route": _S, "source_chain": _S, "source_asset": _S, "destination_chain": _S, "destination_asset": _S, - "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane", "cctp"]}}, []), _h_pause_status), + "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane", "cctp", "verulink"]}}, []), _h_pause_status), ("bridge_quote", "Validate and price a transfer: route, fees (human units), amount_out, approval needs. Reads chain state, " "never signs. ALWAYS quote before bridge_execute and show the user fees and amount_out. A private mint " diff --git a/bridge-sdk/python/aleo_bridge/checkpoint.py b/bridge-sdk/python/aleo_bridge/checkpoint.py index 4cc603fe..81cdab0b 100644 --- a/bridge-sdk/python/aleo_bridge/checkpoint.py +++ b/bridge-sdk/python/aleo_bridge/checkpoint.py @@ -205,6 +205,8 @@ def create_checkpoint(plan: Plan, receipt: Receipt, registry: Registry) -> Check if plan.cctp is not None: from dataclasses import asdict intent["cctp"] = asdict(plan.cctp) + if plan.protocol == "verulink" and plan.amount_out is not None: + intent["amountOut"] = plan.amount_out 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 8376ae4e..4a3de976 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -18,6 +18,7 @@ from . import _evm_connections from ._calls import AleoCall from .cctp import CctpModule +from .verulink import VerulinkModule from .errors import BridgeError, ConfigurationError, UnsupportedRouteError from .eth import Ethereum, EthModule from .freezelist import ComplianceProofs, FreezeList @@ -153,6 +154,7 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir self.hyperlane = HyperlaneModule(self) self.xreserve = XReserveModule(self) self.cctp = CctpModule(self) + self.verulink = VerulinkModule(self) self.freezelist = FreezeList(self) self.privacy = PrivacyModule(self) @@ -356,7 +358,8 @@ def pause_status(self, route, *, sides=("source", "destination")) -> PauseStatus Both ends are read unless ``sides`` narrows it: the ``pause`` mapping and enrolled router of the Aleo warp program (plus the ARC-20 token's ``pause__``), the USDCx bridge and stablecoin pauses, ``paused()`` on xReserve and the CCTP transmitter, any ``PausableHook`` / ``PausableIsm`` in the - Ethereum Mailbox's trees, and the Solana token PDA's enrolled router. Nothing is cached and the + Ethereum Mailbox's trees, the Solana token PDA's enrolled router, and Verulink's Aleo bridge/service + settings and BSC bridge/service pauses. Nothing is cached and the result never raises for a pause: ``status.paused`` / ``status.unknown`` say so and ``status.reason`` names the switch. ``quote``, ``execute``, ``resume`` and ``complete`` run this same check and refuse with ``RoutePausedError`` or ``PauseCheckError``. A far-side EVM chain with no @@ -374,7 +377,8 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N Name the route the way veil's ``quote`` does: ``source_chain`` + ``source_asset`` + ``destination_chain`` (``"ethereum"``, ``"usdc"``, ``"aleo"``), adding - ``destination_asset`` / ``bridge_protocol`` (``"xreserve"`` | ``"hyperlane"``) + ``destination_asset`` / ``bridge_protocol`` (``"xreserve"``, ``"hyperlane"``, + ``"cctp"``, or ``"verulink"``) only when more than one route fits — or ``route=`` with a ``Route`` from ``routes()`` or its id. Give exactly one of ``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is the destination-chain @@ -382,11 +386,15 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N ``"record"`` minted by the relayer, or ``"private"`` — you finish it yourself with ``complete`` and must keep ``secret_nonce``. Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / - solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with + solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve / + evm-cctp / aleo-verulink / evm-verulink) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the user fees + amount before ``execute``. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either end reads paused or cannot be read (see ``pause_status``). + Verulink supports public ALEO + between Aleo and BSC; both directions require a destination claim. + Unpriced network gas has ``Fee.amount=None``, never zero. """ return _lifecycle.quote(self, source_chain=source_chain, source_asset=source_asset, destination_chain=destination_chain, destination_asset=destination_asset, @@ -472,9 +480,9 @@ def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = Non 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. + """Claim a private USDCx, native-USDC CCTP, or public Verulink destination. - Requires the same ``secret_nonce`` given to ``execute``; the SDK never + Private xReserve 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. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` @@ -482,6 +490,8 @@ def complete(self, progress, *, secret_nonce: str | None = None, on_checkpoint=N submitted destination transaction is observed rather than repeated. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on the destination side reads paused or cannot be read. + Verulink requires a destination signer and native gas, with no secret + nonce. A holding result retains its checkpoint for review and recovery. """ options = {"manual_mint": manual_mint} if manual_mint else {} return _lifecycle.complete(self, progress, secret_nonce=secret_nonce, on_checkpoint=on_checkpoint, @@ -546,7 +556,7 @@ def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: """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``; a chain neither names falls back to a read-only connection over the profile's ``rpc_urls``.""" - kwargs = {k: v for k, v in (("network", network), ("endpoint", endpoint)) if v is not None} + kwargs: dict[str, Any] = {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")) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 98240070..e088cbc0 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -259,6 +259,8 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = if plan.protocol == "cctp": return bridge.cctp.quote(plan) + if plan.protocol == "verulink": + return bridge.verulink.quote(plan) if plan.protocol == "hyperlane" and family == "evm": q = _module(bridge, "eth", resolved.source_chain.id).quote_transfer_remote(plan=plan) return replace(q, plan=plan) @@ -310,12 +312,15 @@ def _keeps_checkpoint(receipt: Receipt) -> bool: """A terminal receipt whose record must SURVIVE: an EVM source transaction that was dropped or replaced before it mined (``protocol_state["dropped"]``). - Every other terminal status means the transfer is settled or its funds are gone, and the + Verulink holding receipts also keep their source and claim evidence for review/release. + Other terminal statuses mean the transfer is settled or its funds are gone, and the checkpoint has nothing left to do. A dropped one is the opposite: nothing moved, and this record is the only thing ``recover()`` can re-scan source history from once the RPC agrees with itself. Deleting it would leave the operator with a hash and no transfer. """ - return receipt.status in TERMINAL and receipt.protocol_state.get("dropped") is True + return receipt.status in TERMINAL and ( + receipt.protocol_state.get("dropped") is True + or receipt.protocol == "verulink" and receipt.protocol_state.get("held") is True) def _persist(bridge, checkpoint: Checkpoint, receipt: Receipt, *, previous_id: str | None = None) -> None: @@ -609,6 +614,13 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin plan = replace(plan, journal_id=reserve(plan)) emit = _Emitter(bridge, plan, on_checkpoint) + if plan.protocol == "verulink": + receipt = bridge.verulink.execute(plan, on_checkpoint=emit, proving=proving, mode=mode, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) + if plan.protocol == "cctp": receipt = bridge.cctp.execute(plan, on_checkpoint=emit, poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) emit(receipt) @@ -789,6 +801,8 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: route, src, dst = resolved.route, resolved.source_chain, resolved.destination_chain if plan.protocol == "cctp": return bridge.cctp.get_status(plan, receipt) + if plan.protocol == "verulink": + return bridge.verulink.get_status(plan, receipt) state = receipt.protocol_state # 1. EVM approval → wallet boundary @@ -1081,13 +1095,19 @@ def _plan_from_intent(registry: Registry, intent: dict[str, Any]) -> Plan: passes ``EthModule``/``SolModule``'s field-by-field ``plan=`` checks. """ try: - return prepare(registry, + plan = prepare(registry, source_chain=intent["source"]["chain"], source_asset=intent["source"]["asset"], destination_chain=intent["destination"]["chain"], 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"), cctp=intent.get("cctp")) + if plan.protocol == "verulink" and intent.get("amountOut") is not None: + amount_out = intent["amountOut"] + if not isinstance(amount_out, str) or not 0 < parse_decimal_amount(amount_out, 6) <= plan.amount_atomic: + raise CheckpointInvalidError("Invalid Verulink quoted output in checkpoint") + plan = replace(plan, amount_out=amount_out) + return plan except (KeyError, TypeError) as exc: raise CheckpointInvalidError(f"Bridge checkpoint intent is incomplete: missing {exc}") from exc @@ -1155,6 +1175,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 == "verulink": + from .verulink import VerulinkModule + return VerulinkModule.from_checkpoint(plan, cp) if plan.protocol == "cctp": from .cctp import CctpModule return CctpModule.from_checkpoint(plan, cp) @@ -1232,6 +1255,8 @@ def _apply_destination_overlay(resolved: ResolvedRoute, cp: Checkpoint, receipt: never unsafe — only ever a prompt to call the verb that actually checks. """ destination = cp.destination or {} + if resolved.route.protocol == "verulink": + return receipt if not destination: return receipt if resolved.route.protocol == "cctp" and not destination.get("preparedTransaction"): @@ -1327,6 +1352,10 @@ def recover(bridge, checkpoint, *, approval_replacement=None) -> Progress: if approval_replacement is not None: raise ConfigurationError("approval_replacement is only supported for CCTP") + if plan.protocol == "verulink": + receipt = bridge.verulink.recover(plan, cp) + return _finish(bridge, plan, receipt, cp.id) + if src.family in ("aleo", "solana"): receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) if receipt.status is Status.SOURCE_SUBMISSION_PENDING: @@ -1449,6 +1478,13 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, state = receipt.protocol_state family = resolved.source_chain.family + if plan.protocol == "verulink": + emit.supersede(receipt.id) + receipt = bridge.verulink.resume(plan, receipt, on_checkpoint=emit) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) + if plan.protocol == "cctp": emit.supersede(receipt.id) receipt = bridge.cctp.execute(plan, resume=receipt, on_checkpoint=emit, @@ -1573,6 +1609,14 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, return to_progress(plan, receipt) if manual_mint: raise ConfigurationError("manual_mint is only supported for CCTP") + if progress.plan.protocol == "verulink": + plan = progress.plan + _require_unpaused(bridge, resolve_route(bridge.registry, plan).route, sides=("destination",)) + emit = _Emitter(bridge, plan, on_checkpoint) + receipt = bridge.verulink.complete(plan, progress.receipt, on_checkpoint=emit, proving=proving) + emit(receipt) + emit.finalize() + return to_progress(plan, receipt) 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/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index deb1eaca..d1d83ffc 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -145,15 +145,16 @@ def _evm_call(w3: Any, address: str, fn: str, *args: Any, sleep: Callable[[float does not answer this getter), never retried.""" from .eth import _web3 from ._evm_abi import PAUSE_ABI + from web3.exceptions import ContractLogicError web3 = _web3() checksummed = web3.Web3.to_checksum_address(address) def call(attempt: int) -> Any: - client = w3(attempt) if callable(w3) else w3 + client: Any = w3(attempt) if callable(w3) else w3 return getattr(client.eth.contract(address=checksummed, abi=PAUSE_ABI).functions, fn)(*args).call() try: return read_with_retry(call, sleep=sleep) - except web3.exceptions.ContractLogicError as exc: + except ContractLogicError as exc: raise ConfigurationError(f"{fn}() on {address} reverted: {exc}") from exc @@ -289,6 +290,26 @@ def _solana(self) -> Any: # -- switches per chain family -- def _aleo(self, side: str, route: Route, asset: Asset, far_chain: Chain) -> list[PauseSwitch]: bridge, out = self._bridge, [] + if route.protocol == "verulink": + service, token_bridge = route.meta_str("aleoTokenService"), route.meta_str("aleoBridge") + domain = route.meta_str("evmDomain") + token = f"{{ chain_id: {domain}u128, token_id: {route.meta_str('aleoTokenId')} }}" + settings = ( + (service, "status", token, "false", "true"), + (token_bridge, "bridge_settings", "3u8", "1u8", "0u8"), + (token_bridge, "supported_chains", domain + "u128", "true", "false"), + (token_bridge, "supported_services", route.meta_str("aleoServiceAddress"), "true", "false"), + ) + for program, mapping, key, open_value, paused_value in settings: + switch = f"{mapping}[{key}]" + try: + value = _aleo_mapping_value(bridge, program, mapping, key, self._sleep) + except ReadFailed as exc: + out.append(PauseSwitch(asset.chain_id, side, program, switch, "unknown", str(exc))) + continue + state = "open" if value == open_value else "paused" if value == paused_value else "unknown" + out.append(PauseSwitch(asset.chain_id, side, program, switch, state, value)) + return out if route.protocol == "hyperlane": program = asset.locator.value if asset.locator is not None else route.meta_str("aleoRouterProgram") convention = declared_pause_mapping(bridge, program, sleep=self._sleep) @@ -313,6 +334,9 @@ def _aleo(self, side: str, route: Route, asset: Asset, far_chain: Chain) -> list def _evm(self, side: str, route: Route, chain: Chain, far_chain: Chain) -> list[PauseSwitch]: w3 = self.evm_w3(chain.id) + if route.protocol == "verulink": + return [read_evm_paused(w3, chain_id=chain.id, side=side, address=route.meta_str(key), sleep=self._sleep) + for key in ("evmTokenService", "evmBridge")] if route.protocol == "hyperlane": reader = read_mailbox_hook_pauses if side == "source" else read_mailbox_ism_pauses return reader(w3, chain_id=chain.id, side=side, mailbox=route.meta_str("mailboxAddress"), diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py index 1c05fd8c..5de60f0b 100644 --- a/bridge-sdk/python/aleo_bridge/profile.py +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -69,7 +69,7 @@ def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mai home_path.mkdir(parents=True, exist_ok=True, mode=0o700) home_path.chmod(0o700) # mkdir's mode is subject to umask; make it exact private_key, address = _initial_key(network) - data = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} + data: dict[str, Any] = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} if rpc_urls: data["rpc_urls"] = dict(rpc_urls) try: diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py index b523ed38..8d0f1fc2 100644 --- a/bridge-sdk/python/aleo_bridge/types.py +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -53,8 +53,8 @@ class Step: class Fee: kind: str chain_id: str - asset_id: str - amount: str + asset_id: str | None + amount: str | None estimated: bool @@ -115,10 +115,14 @@ class Plan: #: 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 + #: Minimum net output approved by a Verulink quote; omitted for other protocols. + amount_out: str | None = None def to_dict(self) -> dict[str, Any]: d = dataclasses.asdict(self) d["steps"] = [dataclasses.asdict(s) for s in self.steps] + if self.amount_out is None: + d.pop("amount_out") return d @classmethod @@ -138,6 +142,14 @@ class Quote: amount_out: str | None +@dataclass(frozen=True) +class VerulinkQuote(Quote): + """Current Verulink fee and public ALEO output; network gas is unpriced.""" + amount_atomic: int + fee_atomic: int + rate: int + + @dataclass(frozen=True) class EvmCctpQuote(Quote): """Report the CCTP deduction and receive amount in exact USDC base units.""" diff --git a/bridge-sdk/python/aleo_bridge/verulink.py b/bridge-sdk/python/aleo_bridge/verulink.py new file mode 100644 index 00000000..329e2643 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/verulink.py @@ -0,0 +1,297 @@ +"""Move public ALEO between Aleo and BSC with an explicit destination claim. + +Quote and execute through ``Bridge``. Save checkpoints and use ``wait``, +``recover``, ``resume``, and ``complete`` for an existing transfer. +""" +from __future__ import annotations + +from dataclasses import replace +import time +from typing import Any, Callable + +import requests + +from ._verulink_context import Context +from ._verulink_wire import U64_MAX, ZERO_ALEO, address_literal, incoming, integer, same +from ._verulink_source import (inputs, prepared_transaction, tx_hash, validate_aleo_claim, validate_aleo_source) +from ._verulink_attestation import verified_signatures +from . import _verulink_status as status +from .checkpoint import Checkpoint, create_checkpoint +from .errors import BridgeError +from .types import Fee, Plan, Receipt, Status, VerulinkQuote +from .units import format_decimal_amount, parse_decimal_amount + + +class VerulinkCheckpointError(BridgeError): + """A submission needs recovery from the attached checkpoint after persistence failed.""" + + def __init__(self, tx_id: str, checkpoint: Checkpoint) -> None: + super().__init__(f"Verulink transaction {tx_id} may be submitted; recover the attached checkpoint, never execute again") + self.broadcast_id = tx_id + self.checkpoint = checkpoint + + +class VerulinkModule: + """Read public ALEO bridge fees and verify Verulink delivery.""" + + def __init__(self, bridge: Any) -> None: + self.bridge = bridge + self.session: Any = requests.Session() + + def context(self, plan: Plan) -> Context: + return Context(self.bridge, plan) + + def quote(self, plan: Plan) -> VerulinkQuote: + """Read current fees and fix the minimum public ALEO output without signing.""" + c = self.context(plan) + c.assert_active() + rate, minimum, maximum = c.configuration() + amount = plan.amount_atomic + if c.outbound and amount * rate > U64_MAX: + raise BridgeError("Verulink Aleo fee multiplication exceeds u64") + fee = amount * rate // 100_000 + net = amount - fee + if net <= 0 or net < minimum: + raise BridgeError(f"Verulink net amount is below minimum {format_decimal_amount(minimum, 6)} ALEO") + if net > maximum: + raise BridgeError(f"Verulink net amount exceeds maximum {format_decimal_amount(maximum, 6)} ALEO") + output = format_decimal_amount(net, 6) + fees = (Fee("protocol", c.source.chain_id, c.source.id, format_decimal_amount(fee, 6), True), + Fee("network", c.source.chain_id, None, None, True), + Fee("network", c.destination.chain_id, None, None, True)) + return VerulinkQuote("aleo-verulink" if c.outbound else "evm-verulink", replace(plan, amount_out=output), + fees, output, amount, fee, rate) + + def _save(self, plan: Plan, receipt: Receipt, emit: Callable[[Receipt], Any], *, submitted: bool = False) -> None: + checkpoint = create_checkpoint(plan, receipt, self.bridge.registry) + try: + # Persist independently of user notification; a failed callback must not lose a hash. + if self.bridge.checkpoints is not None: + self.bridge.checkpoints.save(checkpoint) + emit(receipt) + except Exception as exc: + if submitted: + raise VerulinkCheckpointError(receipt.destination_tx_id or receipt.id, checkpoint) from exc + raise + + def _send(self, c: Context, plan: Plan, receipt: Receipt, to: str, name: str, args: list[Any], + emit: Callable[[Receipt], Any], *, approval: bool = False, destination: bool = False) -> Receipt: + sender = c.evm.require_address() + data = c.contract(to).encode_abi(name, args=args) + tx = {"from": sender, "to": to, "chainId": c.evm_chain_id, "data": data, "value": 0} + c.evm.w3.eth.call({k: v for k, v in tx.items() if k != "chainId"}) + + def submitted(tx_id: str) -> Receipt: + tx_hash(tx_id) + if approval: + return status.update(receipt.replace(id=tx_id), Status.SOURCE_APPROVAL_PENDING, + approvalTxIds=[*receipt.protocol_state.get("approvalTxIds", []), tx_id]) + if destination: + return status.update(receipt.replace(destination_tx_id=tx_id), Status.DESTINATION_CONFIRMING) + return status.update(receipt.replace(id=tx_id, source_tx_id=tx_id), Status.SOURCE_CONFIRMING) + + try: + tx_id = c.evm.send_transaction(tx) + except BridgeError as exc: + if getattr(exc, "broadcast_id", None): + self._save(plan, submitted(getattr(exc, "broadcast_id")), emit, submitted=True) + raise + result = submitted(tx_id) + self._save(plan, result, emit, submitted=True) + return result + + def _prepare(self, c: Context, function: str, args: list[str], proving: str) -> Any: + from .lifecycle import _prepare_aleo + call = self.bridge._call(c.aleo_service, function, args, lambda tx_id, outputs: tx_id) + return _prepare_aleo(call, proving) + + def execute(self, plan: Plan, *, on_checkpoint: Callable[[Receipt], Any], proving: str = "delegate", + mode: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0) -> Receipt: + """Submit public source funds after checking fresh fees and peer configuration.""" + from .lifecycle import _connected_aleo_address, submit_serialized + if mode not in (None, "public", "public-as-signer", "signer"): + raise BridgeError("Verulink only supports public source balances") + c = self.context(plan) + + def price() -> VerulinkQuote: + q = self.quote(plan) + assert q.amount_out is not None + if plan.amount_out is not None and parse_decimal_amount(q.amount_out, 6) < parse_decimal_amount(plan.amount_out, 6): + raise BridgeError("Verulink fee increased since quoting; quote again before submission") + return q + + priced = price() + sender = _connected_aleo_address(self.bridge) if c.outbound else c.evm.require_address() + if not sender or (plan.sender is not None and not same(sender, plan.sender)): + raise BridgeError("Verulink source sender does not match the wallet account") + state = {"routeId": plan.route_id, "sourceSender": sender} + if c.outbound: + if integer(c.mapping("credits.aleo", "account", sender), "public balance") < plan.amount_atomic: + raise BridgeError("Insufficient public ALEO balance for Verulink") + args = [address_literal(plan.recipient), f"{plan.amount_atomic}u64", c.evm_domain + "u128", + address_literal(c.evm_service), address_literal(c.evm_token), f"{priced.fee_atomic}u64"] + prepared = self._prepare(c, "token_send_public", args, proving) + tx = prepared_transaction(prepared.serialized, prepared.transaction_id) + validate_aleo_source(c, replace(plan, sender=sender), tx) + pending = Receipt(prepared.transaction_id, "verulink", Status.SOURCE_SUBMISSION_PENDING, + protocol_state={**state, "preparedTransaction": prepared.serialized}) + self._save(plan, pending, on_checkpoint) + submit_serialized(self.bridge, prepared.serialized, prepared.transaction_id) + result = Receipt(prepared.transaction_id, "verulink", Status.SOURCE_CONFIRMING, + source_tx_id=prepared.transaction_id, protocol_state=state) + self._save(plan, result, on_checkpoint, submitted=True) + return result + if integer(c.read(c.evm_token, "balanceOf", [sender]), "wrapped balance", 2**256 - 1) < plan.amount_atomic: + raise BridgeError("Insufficient wrapped ALEO balance") + if c.evm.w3.eth.get_balance(sender) <= 0: + raise BridgeError("BNB is required to pay Verulink source gas") + pending = Receipt("unsubmitted", "verulink", Status.PREPARED, protocol_state=state) + if integer(c.read(c.evm_token, "allowance", [sender, c.evm_service]), "allowance", 2**256 - 1) < plan.amount_atomic: + pending = self._send(c, plan, pending, c.evm_token, "approve", [c.evm_service, plan.amount_atomic], on_checkpoint, approval=True) + deadline = time.monotonic() + timeout_seconds + while True: + result = status.approval_status(c, replace(plan, sender=sender), pending) + if result.status == Status.FAILED: + return result + if result.status == Status.SOURCE_SUBMISSION_PENDING: + break + remaining = deadline - time.monotonic() + if remaining <= 0: + return pending + time.sleep(min(max(0.01, poll_seconds), remaining)) + if integer(c.read(c.evm_token, "allowance", [sender, c.evm_service]), "allowance", 2**256 - 1) < plan.amount_atomic: + raise BridgeError("Confirmed Verulink approval did not grant the required allowance") + price() # Approval confirmation may take minutes; re-read before the burn. + return self._send(c, plan, pending, c.evm_service, "publicTokenSend", [c.evm_token, plan.amount_atomic, plan.recipient], on_checkpoint) + + def get_status(self, plan: Plan, receipt: Receipt) -> Receipt: + """Verify source, attestors and destination delivery without submitting.""" + return status.get_status(self.context(plan), plan, receipt, self.session) + + @staticmethod + def from_checkpoint(plan: Plan, checkpoint: Checkpoint) -> Receipt: + source, dest = checkpoint.source or {}, checkpoint.destination or {} + outbound = plan.source_asset_id.split("/")[0] == "aleo" + prepared, claim = source.get("preparedTransaction"), dest.get("preparedTransaction") + approvals = source.get("approvalTransactionIds", []) + if not isinstance(approvals, list): + raise BridgeError("Invalid Verulink approval checkpoint") + for value in approvals: + tx_hash(value) + tx_id, dest_id = source.get("transactionId"), dest.get("transactionId") + if prepared is not None and (not outbound or tx_id or dest or approvals): + raise BridgeError("Invalid Verulink prepared source checkpoint") + if claim is not None and (outbound or dest_id): + raise BridgeError("Invalid Verulink prepared destination checkpoint") + state: dict[str, Any] = {"routeId": plan.route_id, "sourceSender": plan.sender, "approvalTxIds": approvals} + if prepared is not None: + if not isinstance(prepared, dict): + raise BridgeError("Invalid Verulink prepared source checkpoint") + tx = prepared_transaction(prepared.get("serializedTransaction"), prepared.get("transactionId")) + return Receipt(tx["id"], "verulink", Status.SOURCE_SUBMISSION_PENDING, + protocol_state={**state, "preparedTransaction": prepared["serializedTransaction"]}) + if not tx_id and (not approvals or dest) or outbound and approvals: + raise BridgeError("Invalid Verulink checkpoint source identifiers") + if tx_id is not None and (not isinstance(tx_id, str) or not tx_id): + raise BridgeError("Invalid Verulink source transaction id") + if tx_id and not outbound: + tx_hash(tx_id) + if dest_id is not None and (not isinstance(dest_id, str) or not dest_id): + raise BridgeError("Invalid Verulink claim transaction id") + if dest_id and outbound: + tx_hash(dest_id) + receipt_id = tx_id or approvals[-1] + if claim is not None: + if not isinstance(claim, dict): + raise BridgeError("Invalid Verulink prepared destination checkpoint") + tx = prepared_transaction(claim.get("serializedTransaction"), claim.get("transactionId")) + receipt_id = tx["id"] + state["preparedDestinationTransaction"] = claim["serializedTransaction"] + return Receipt(receipt_id, "verulink", Status.DESTINATION_CONFIRMING if dest_id else + Status.SOURCE_CONFIRMING if tx_id else Status.SOURCE_APPROVAL_PENDING, + source_tx_id=tx_id, destination_tx_id=dest_id, protocol_state=state) + + def recover(self, plan: Plan, checkpoint: Checkpoint) -> Receipt: + """Reconstruct a saved transfer and read its current chain evidence.""" + c = self.context(plan) + r = self.from_checkpoint(plan, checkpoint) + if "preparedTransaction" in r.protocol_state: + validate_aleo_source(c, plan, prepared_transaction(r.protocol_state["preparedTransaction"], r.id)) + return r + return self.get_status(plan, r) + + def resume(self, plan: Plan, receipt: Receipt, *, on_checkpoint: Callable[[Receipt], Any]) -> Receipt: + """Rebroadcast the saved Aleo bytes; an ambiguous EVM source needs its wallet hash.""" + from .lifecycle import submit_serialized + c = self.context(plan) + if not c.outbound: + raise BridgeError("Verulink approval-only recovery cannot safely resubmit; recover with the source transaction hash from the wallet") + c.assert_active() + c.configuration() + serialized = receipt.protocol_state.get("preparedTransaction") + if not isinstance(serialized, str): + raise BridgeError("Invalid Verulink prepared source transaction") + tx = prepared_transaction(serialized, receipt.id) + validate_aleo_source(c, plan, tx) + self._save(plan, receipt, on_checkpoint) + submit_serialized(self.bridge, serialized, receipt.id) + state = dict(receipt.protocol_state) + state.pop("preparedTransaction", None) + result = receipt.replace(source_tx_id=receipt.id, status=Status.SOURCE_CONFIRMING, next_action=None, protocol_state=state) + self._save(plan, result, on_checkpoint, submitted=True) + return result + + def complete(self, plan: Plan, receipt: Receipt, *, on_checkpoint: Callable[[Receipt], Any], proving: str = "delegate") -> Receipt: + """Claim a positively attested packet using fresh replay and membership checks.""" + from .lifecycle import submit_serialized + c = self.context(plan) + if receipt.destination_tx_id: + raise BridgeError("Verulink claim already submitted; recover or wait instead of submitting again") + if receipt.status != Status.DESTINATION_ACTION_REQUIRED or (receipt.next_action or {}).get("kind") != "verulink-claim": + raise BridgeError("Verulink receipt has no destination claim ready") + c.assert_active(destination_only=True) + c.configuration() + ready = self.get_status(plan, receipt) + if ready.status != Status.DESTINATION_ACTION_REQUIRED: + raise BridgeError("Verulink packet is consumed or no longer has a positive attestor quorum") + p, signatures = ready.protocol_state["packet"], ready.protocol_state["signatures"] + if c.outbound: + return self._send(c, plan, ready, c.evm_service, "tokenReceive", [incoming(p), bytes.fromhex("".join(s["sign"][2:] for s in signatures))], + on_checkpoint, destination=True) + serialized = receipt.protocol_state.get("preparedDestinationTransaction") + if serialized is not None: + tx = prepared_transaction(serialized, receipt.id) + pending = ready + else: + signers, signed = [s["signer"] for s in signatures], [s["sign"] for s in signatures] + while len(signers) < 5: + signers.append(ZERO_ALEO) + signed.append(signed[0]) + args = [address_literal(p["sender"]), p["recipient"], p["amount"] + "u64", p["sequence"] + "u64", p["height"] + "u64", + "[" + ", ".join(signers) + "]", "[" + ", ".join(signed) + "]", p["sourceChain"] + "u128", address_literal(p["sourceAddress"])] + prepared = self._prepare(c, "token_receive_public", args, proving) + serialized = prepared.serialized + tx = prepared_transaction(serialized, prepared.transaction_id) + pending = ready.replace(id=tx["id"], protocol_state={**ready.protocol_state, "preparedDestinationTransaction": serialized}) + v = inputs(validate_aleo_claim(c, p, tx)) + signers, signed = v[5], v[6] + if not isinstance(signers, list) or not isinstance(signed, list) or len(signers) != 5 or len(signed) != 5: + raise BridgeError("Invalid prepared Verulink attestor arrays") + supplied, padding = [], False + for signer, sign in zip(signers, signed): + if signer == ZERO_ALEO: + padding = True + elif padding or not isinstance(signer, str) or not isinstance(sign, str): + raise BridgeError("Invalid prepared Verulink attestor padding") + else: + supplied.append({"signer": signer, "sign": sign}) + positive = verified_signatures(c, p, supplied) + if positive is None or len(positive) != len(supplied): + raise BridgeError("Prepared Verulink claim lacks a positive attestor quorum") + self._save(plan, pending, on_checkpoint) + submit_serialized(self.bridge, serialized, tx["id"]) + state = dict(ready.protocol_state) + state.pop("preparedDestinationTransaction", None) + result = ready.replace(destination_tx_id=tx["id"], status=Status.DESTINATION_CONFIRMING, next_action=None, protocol_state=state) + self._save(plan, result, on_checkpoint, submitted=True) + return result diff --git a/bridge-sdk/tests/fakes/fake_verulink.py b/bridge-sdk/tests/fakes/fake_verulink.py new file mode 100644 index 00000000..f554683e --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_verulink.py @@ -0,0 +1,106 @@ +"""Real Web3 ABI codec with deterministic RPC and Aleo service responses.""" +import copy +import json +from pathlib import Path +from types import SimpleNamespace + +from eth_abi import encode +from web3 import Web3 +from web3.providers import BaseProvider + +from aleo_bridge import Bridge, Ethereum, prepare, DEFAULT_REGISTRY +from aleo_bridge._verulink_abi import ABI +from aleo_bridge._verulink_deployment import METADATA as M +from aleo_bridge._verulink_wire import address_literal +from tests.conftest import FakeAleo + +FIXTURE = json.loads((Path(__file__).parents[1] / "fixtures/verulink-mainnet.json").read_text()) +ALEO = FIXTURE["toBsc"]["message"]["sender"] +EVM = FIXTURE["toAleo"]["message"]["sender"] + + +class Rpc(BaseProvider): + def __init__(self): + super().__init__() + self.codec = Web3().eth.contract(abi=ABI) + self.chain_id, self.height = 56, 60_000_000 + self.values = {"paused": False, "self": [int(M["evmDomain"]), M["evmTokenService"]], + "destChainId": int(M["aleoDomain"]), "erc20Bridge": M["evmBridge"], + "supportedTokens": [M["evmToken"], "0x" + "00" * 20, M["aleoTokenId"], M["aleoServiceAddress"], 10_000_000, 10**15, True], + "feeCollector": M["evmTokenService"], "platformFees": 100, "decimals": 6, + "balanceOf": 10**15, "allowance": 10**15, "quorumRequired": 3, "isAttestor": True, + "consumedPackets": bytes(32), "holding": "0x" + "88" * 20} + self.calls = [] + self.transactions, self.receipts = {}, {} + + def make_request(self, method, params): + if method == "eth_chainId": + result = hex(self.chain_id) + elif method == "eth_call": + function, args = self.codec.decode_function_input(params[0]["data"]) + self.calls.append((function.fn_name, args)) + if function.fn_name in ("publicTokenSend", "tokenReceive"): + result = "0x" + else: + value = True if function.fn_name == "approve" else self.values[function.fn_name] + types = [o["type"] for o in function.abi["outputs"]] + result = "0x" + encode(types, [value] if len(types) == 1 else value).hex() + elif method == "eth_getBalance": + result = hex(10**18) + elif method == "eth_blockNumber": + result = hex(self.height) + elif method == "eth_getTransactionReceipt": + result = self.receipts.get(params[0]) + elif method == "eth_getTransactionByHash": + result = self.transactions.get(params[0]) + else: + raise AssertionError(f"Unexpected RPC: {method}") + return {"jsonrpc": "2.0", "id": 1, "result": result} + + +class Harness: + def __init__(self, outbound=True): + self.outbound = outbound + self.rpc = Rpc() + self.evm = Ethereum(w3=Web3(self.rpc)) + self.evm.w3.eth.default_account = Web3.to_checksum_address(EVM) + self.sent, self.saved = [], [] + self.evm.send_transaction = self.send + key = f"{{ chain_id: {M['evmDomain']}u128, token_id: {M['aleoTokenId']} }}" + mappings = { + M["aleoTokenService"]: {name: {key: value} for name, value in { + "platform_fee": "100u32", "min_transfers": "10000000u64", "max_transfers": "1000000000000000u64", + "status": "false", "other_chain_token_service": address_literal(M["evmTokenService"], True), + "other_chain_token_address": address_literal(M["evmToken"], True)}.items()}, + M["aleoBridge"]: {"bridge_settings": {"1u8": "3u8", "3u8": "1u8"}, + "supported_chains": {M["evmDomain"] + "u128": "true"}, "supported_services": {M["aleoServiceAddress"]: "true"}, + "sequences": {M["evmDomain"] + "u128": "2u64"}, "out_packets": { + f"{{ chain_id: {M['evmDomain']}u128, sequence: 1u64 }}": FIXTURE["aleoPacket"]}, + "attestors": {s["signer"]: "true" for s in FIXTURE["toAleo"]["status"]["signatures"]}}, + "credits.aleo": {"account": {ALEO: "1000000000000000u64"}}} + self.aleo = FakeAleo(mappings=mappings) + self.aleo.default_account.address = ALEO + self.aleo.confirmed_transactions[FIXTURE["aleoSource"]["transaction"]["id"]] = copy.deepcopy(FIXTURE["aleoSource"]) + self.aleo._client = SimpleNamespace(_get=lambda *args: "ab1historical") + self.aleo.network.get_block_by_hash = lambda *args: {"header": {"metadata": {"height": 10461039}}} + tx = copy.deepcopy(FIXTURE["bscSource"]["tx"]) + tx["blockNumber"] = hex(int(tx["blockNumber"])) + receipt = copy.deepcopy(FIXTURE["bscSource"]["receipt"]) + receipt["blockNumber"] = hex(int(receipt["blockNumber"])) + receipt["status"] = "0x1" + self.rpc.transactions[tx["hash"]] = tx + self.rpc.receipts[tx["hash"]] = receipt + self.bridge = Bridge(self.aleo, evm={"bsc": self.evm}) + self.rows = [copy.deepcopy(FIXTURE["toBsc" if outbound else "toAleo"])] + self.bridge.verulink.session = SimpleNamespace(get=lambda *args, **kw: SimpleNamespace( + status_code=200, json=lambda: {"data": {"currentPage": 1, "totalPages": 1, "data": self.rows}})) + self.plan = prepare(DEFAULT_REGISTRY, source_chain="aleo" if outbound else "bsc", source_asset="aleo", + destination_chain="bsc" if outbound else "aleo", amount="10", + recipient=self.rows[0]["message"]["receiver"], sender=self.rows[0]["message"]["sender"]) + + def send(self, tx): + self.sent.append(tx) + return "0x" + f"{len(self.sent):064x}" + + def quote(self, amount="11.000001"): + return self.bridge.quote(route=self.plan.route_id, amount=amount, recipient=self.plan.recipient, sender=self.plan.sender) diff --git a/bridge-sdk/tests/fixtures/verulink-mainnet.json b/bridge-sdk/tests/fixtures/verulink-mainnet.json new file mode 100644 index 00000000..a3446240 --- /dev/null +++ b/bridge-sdk/tests/fixtures/verulink-mainnet.json @@ -0,0 +1,299 @@ +{ + "aleoSource": { + "status": "accepted", + "type": "execute", + "index": 18, + "transaction": { + "type": "execute", + "id": "at15f8dsunggz4rs0gdgx9wj5ewpefztu97wjftvku4436tz6saqqxq3hey99", + "execution": { + "transitions": [ + { + "id": "au1lx4nx7x3ps4268dvmdftptqgc02fqvtu93fj3xxpjtc827syvy9swmnvjp", + "program": "credits.aleo", + "function": "transfer_public_as_signer", + "inputs": [ + { + "type": "public", + "id": "6061630363533526629250113846034669888307972797856567489975355798860839133393field", + "value": "aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq" + }, + { + "type": "public", + "id": "5097078918669677625494471174559147487550136192795636215176709935348842976430field", + "value": "10000000u64" + } + ], + "outputs": [ + { + "type": "future", + "id": "5518220667102244430568051299247898756513128418591417913192511807682127461285field", + "value": "{\n program_id: credits.aleo,\n function_name: transfer_public_as_signer,\n arguments: [\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq,\n 10000000u64\n ]\n}" + } + ] + }, + { + "id": "au1xztp65pddgf59mfr478xj4xk0xg8dznl2cmq55t349cwnyd065ysvahuj9", + "program": "credits.aleo", + "function": "transfer_public_as_signer", + "inputs": [ + { + "type": "public", + "id": "1694840503034854231538719585219719274611588835734484894452598728919367129629field", + "value": "aleo1g00fsmj8chmqa7df7gq78pnf6tz5lsn3tj94zgvwejlu2me04vpsufhkaa" + }, + { + "type": "public", + "id": "719090736648505858533209992928801752495307931052067593449927522518514047685field", + "value": "0u64" + } + ], + "outputs": [ + { + "type": "future", + "id": "5325686880839254396245824220718805125187855511317130565116868669405568939761field", + "value": "{\n program_id: credits.aleo,\n function_name: transfer_public_as_signer,\n arguments: [\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n aleo1g00fsmj8chmqa7df7gq78pnf6tz5lsn3tj94zgvwejlu2me04vpsufhkaa,\n 0u64\n ]\n}" + } + ] + }, + { + "id": "au1jxm4d4ekwm2vntpdjh04gmjmx3h55trq728faf8cnmn87scy6cxq87p7a7", + "program": "vlink_token_bridge_v3.aleo", + "function": "publish", + "inputs": [ + { + "type": "public", + "id": "5337589268073314760997376151537076141509663567605332275481874592456906240311field", + "value": "1u8" + }, + { + "type": "public", + "id": "5291198399386698985733983683012085373324344548676800507919224210073096134744field", + "value": "422842677816u128" + }, + { + "type": "public", + "id": "1391125782834733047463815942275868206998694162257979313424849463435558634421field", + "value": "[\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n]" + }, + { + "type": "public", + "id": "4394971575266689227070335459734439422274248045742275722145374299022284538059field", + "value": "[\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n]" + }, + { + "type": "public", + "id": "4185312801110148068527070044618631694040978295878914624093798852816299953364field", + "value": "aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds" + }, + { + "type": "public", + "id": "8198219894412318712461299838551623819676111460634492084929195397323271174498field", + "value": "[\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 120u8,\n 180u8,\n 135u8,\n 54u8,\n 22u8,\n 146u8,\n 44u8,\n 132u8,\n 21u8,\n 145u8,\n 136u8,\n 233u8,\n 247u8,\n 9u8,\n 139u8,\n 109u8,\n 87u8,\n 215u8,\n 119u8,\n 195u8\n]" + }, + { + "type": "public", + "id": "6492713215746364303284579024755455701913836521182627072204931717027896673942field", + "value": "10000000u128" + } + ], + "outputs": [ + { + "type": "future", + "id": "6772424746173352489038058711668539816209388827908876998079078210304922245746field", + "value": "{\n program_id: vlink_token_bridge_v3.aleo,\n function_name: publish,\n arguments: [\n 1u8,\n aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq,\n 422842677816u128,\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n],\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n],\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 120u8,\n 180u8,\n 135u8,\n 54u8,\n 22u8,\n 146u8,\n 44u8,\n 132u8,\n 21u8,\n 145u8,\n 136u8,\n 233u8,\n 247u8,\n 9u8,\n 139u8,\n 109u8,\n 87u8,\n 215u8,\n 119u8,\n 195u8\n],\n 10000000u128\n ]\n}" + } + ] + }, + { + "id": "au18kjj4ttnr97lhge6hjultlnh9yxz0xdvwk5emr3ya3twgxdl95rqmrhxta", + "program": "vlink_token_service_cd_v3.aleo", + "function": "token_send_public", + "inputs": [ + { + "type": "public", + "id": "5048420943197810495989424082639958778265815361452727148626223223055542061920field", + "value": "[\n 120u8,\n 180u8,\n 135u8,\n 54u8,\n 22u8,\n 146u8,\n 44u8,\n 132u8,\n 21u8,\n 145u8,\n 136u8,\n 233u8,\n 247u8,\n 9u8,\n 139u8,\n 109u8,\n 87u8,\n 215u8,\n 119u8,\n 195u8\n]" + }, + { + "type": "public", + "id": "1636576964392497594546995227958053023617683241152424921387547175879801450941field", + "value": "10000000u64" + }, + { + "type": "public", + "id": "3324374819297456882423474283427563628461678540541765433033221916478599261880field", + "value": "422842677816u128" + }, + { + "type": "public", + "id": "6895919053956124279006942809385730686442958121435651831657407579010087579746field", + "value": "[\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n]" + }, + { + "type": "public", + "id": "7003835909746110590193023433388323641088173076296323975171704745924716194524field", + "value": "[\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n]" + }, + { + "type": "public", + "id": "7807828541177277942128452948196876738708825766791439764174909560056888317723field", + "value": "0u64" + } + ], + "outputs": [ + { + "type": "future", + "id": "6267074570335925855308404134109731544836701433267701770634997437324378251139field", + "value": "{\n program_id: vlink_token_service_cd_v3.aleo,\n function_name: token_send_public,\n arguments: [\n 10000000u64,\n {\n chain_id: 422842677816u128,\n token_id: 3443843282313283355522573239085696902919850365217539366784739393210722344986field\n},\n [\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n],\n [\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n],\n 0u64,\n {\n program_id: credits.aleo,\n function_name: transfer_public_as_signer,\n arguments: [\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq,\n 10000000u64\n ]\n },\n {\n program_id: credits.aleo,\n function_name: transfer_public_as_signer,\n arguments: [\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n aleo1g00fsmj8chmqa7df7gq78pnf6tz5lsn3tj94zgvwejlu2me04vpsufhkaa,\n 0u64\n ]\n },\n {\n program_id: vlink_token_bridge_v3.aleo,\n function_name: publish,\n arguments: [\n 1u8,\n aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq,\n 422842677816u128,\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n],\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n],\n aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 120u8,\n 180u8,\n 135u8,\n 54u8,\n 22u8,\n 146u8,\n 44u8,\n 132u8,\n 21u8,\n 145u8,\n 136u8,\n 233u8,\n 247u8,\n 9u8,\n 139u8,\n 109u8,\n 87u8,\n 215u8,\n 119u8,\n 195u8\n],\n 10000000u128\n ]\n }\n \n ]\n}" + } + ] + } + ], + "global_state_root": "sr1vnkt5t8v6sh9vc5q3v43z28kc2gdaq7c4l0zk2sm5pk80sx34gqskr2psw" + } + }, + "finalize": [] + }, + "aleoPacket": "{\n version: 1u8,\n sequence: 1u64,\n source: {\n chain_id: 6694886634401u128,\n addr: aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq\n },\n destination: {\n chain_id: 422842677816u128,\n addr: [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 71u8,\n 82u8,\n 57u8,\n 114u8,\n 152u8,\n 121u8,\n 58u8,\n 125u8,\n 184u8,\n 134u8,\n 190u8,\n 252u8,\n 108u8,\n 142u8,\n 56u8,\n 114u8,\n 157u8,\n 38u8,\n 151u8,\n 68u8\n ]\n },\n message: {\n sender_address: aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds,\n dest_token_address: [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 108u8,\n 255u8,\n 250u8,\n 91u8,\n 253u8,\n 66u8,\n 119u8,\n 160u8,\n 77u8,\n 131u8,\n 48u8,\n 127u8,\n 238u8,\n 223u8,\n 226u8,\n 209u8,\n 141u8,\n 148u8,\n 77u8,\n 210u8\n ],\n amount: 10000000u128,\n receiver_address: [\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 0u8,\n 120u8,\n 180u8,\n 135u8,\n 54u8,\n 22u8,\n 146u8,\n 44u8,\n 132u8,\n 21u8,\n 145u8,\n 136u8,\n 233u8,\n 247u8,\n 9u8,\n 139u8,\n 109u8,\n 87u8,\n 215u8,\n 119u8,\n 195u8\n ]\n },\n height: 10461039u64\n}", + "toBsc": { + "packetId": "826180cb9dfc0a938e30357ee03709cf6ecf9b1050ea685ab29c7aa0d4670fd1", + "version": "1", + "destinationAddress": "0x4752397298793a7db886befc6c8e38729d269744", + "sourceAddress": "aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq", + "destinationChain": "422842677816", + "sourceChain": "6694886634401", + "height": "10461039", + "timestamp": "1762620321052", + "sequence": 1, + "platformFee": "1000000.00", + "relayerFee": "0.00", + "updatedAt": "2025-11-08T16:46:06.878Z", + "status": { + "id": 45086583, + "signatureCount": 3, + "signatures": [ + { + "sign": "0xae3b0d92b94619b9d906a40010ab8bc90363c4fd55018aa5dad77af420f889f25574abda8c9e04eec9e398cbe4c30aec2785f991c2b59e9b91a96665debf25141c", + "signer": "0x06a541a3c1b5cebb75b7fdd16183bcfc80e524d8" + }, + { + "sign": "0xdeb721aa1519c46a11dcf8459469f02460fd8974cd1a82623fc1a92cc4896f8301c1800542fe8fd4fed09f0fcd3fbbc7479f67a15f914e5e8369a28434c57a291b", + "signer": "0x89997a88467b7cf061147519c13f76244e564e12" + }, + { + "sign": "0x4f20a9fc485ffebc17e123c215be15c47500c29fb892a5b2d0aa64503afa5593353e9575e0f96771f3f534cf585ab5aa50f0da1d40264b505caa4b8d72585f2c1b", + "signer": "0xb4fd97bd99047ff4b9db9e57a76facbfa5c96ca6" + } + ], + "isClaimed": true, + "updatedAt": "2025-11-08T16:48:03.327Z", + "packetId": "826180cb9dfc0a938e30357ee03709cf6ecf9b1050ea685ab29c7aa0d4670fd1", + "isClaimable": true, + "threshold": 3, + "attestors": 4 + }, + "message": { + "id": 1873, + "receiver": "0x78b4873616922c84159188e9f7098b6d57d777c3", + "sender": "aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds", + "denom": "0x6cfffa5bfd4277a04d83307feedfe2d18d944dd2", + "amount": "10000000", + "pseudoReceiver": null, + "packetId": "826180cb9dfc0a938e30357ee03709cf6ecf9b1050ea685ab29c7aa0d4670fd1" + } + }, + "toAleo": { + "packetId": "d3d241af3de76fcaaa93e83176e867c771c7be640a17ca184daf2f80fca12f7e", + "version": "1", + "destinationAddress": "aleo1er7zejft45vajxr7q0v44n4mw0v32ac6jww7xulnyzpur0wuygzq2atznq", + "sourceAddress": "0x4752397298793a7db886befc6c8e38729d269744", + "destinationChain": "6694886634401", + "sourceChain": "422842677816", + "height": "58692064", + "timestamp": "1756017290000", + "sequence": 1, + "platformFee": "1000000.00", + "relayerFee": "0.00", + "updatedAt": "2025-11-08T16:48:03.550Z", + "status": { + "id": 45087354, + "signatureCount": 3, + "signatures": [ + { + "sign": "sign1rda02g2przfk0v8kvagdxhlmduzwjjnp0fle3685ahafua8gg5qzten3qpruq0m9x6wznqvxtwwxfztxd5r2z7nqw3w8s2p2a2at2qwpae2ed7vk6pcwlk3rmg2sk35fpk9lazt4y8jqu0elzvhw6ej4q0lq7568ng786juc885mjcz0j46zgccnd9nsjnuxkrkj8n5ul2lqx4xj8sr", + "signer": "aleo1jv3agxfz06fmsthgr7u9j4fvy9vhr9up9l790hk3m238t00luqpsrqgjqw" + }, + { + "sign": "sign18d97lstpr8ff9dt5xd6x3gx3fnhp85z66e0v6rx52d78ycfkpyqnxdjq03zryvd8k058zedx53737qf36va0r9ukmjrqmvnyfdcxqqkk4p78j2rvelvwswrntm5600ld22pdejctrztp48p0625w046qqu6np2s0qsq7sar6rj9cwqxtac8e5we0ky35p26rtclady0zwntqv3vg57n", + "signer": "aleo1xtvasjrg63gs4kux8zk6gpszknfrfqskcagwxajl023n7p97lv8qcrz83g" + }, + { + "sign": "sign1m0mcfsv3hkc79dul3ph7dmm9hddrz6fssay9k3fny67g2vp0cuqfa7ywvdrm7q7nrj9q4svhje733y2wn7tyep523s8yr8pwc6d27qwy4q0prvtqzr8jk2l64j299n549f69x7pxd24rk8ha2cv73405qan29caufkcv4jwgtq98k4xlmc9qnsvvn6nke3v5lcf8r2sp9pzs6nxcz7d", + "signer": "aleo1yl6euwf3v5493wu353429q76hrn2a5q5z8n2cu79d4zyvwhypg8slff3cp" + } + ], + "isClaimed": true, + "updatedAt": "2025-11-08T16:48:26.244Z", + "packetId": "d3d241af3de76fcaaa93e83176e867c771c7be640a17ca184daf2f80fca12f7e", + "isClaimable": true, + "threshold": 3, + "attestors": 4 + }, + "message": { + "id": 2258, + "receiver": "aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds", + "sender": "0x78b4873616922c84159188e9f7098b6d57d777c3", + "denom": "3443843282313283355522573239085696902919850365217539366784739393210722344986field", + "amount": "10000000", + "pseudoReceiver": null, + "packetId": "d3d241af3de76fcaaa93e83176e867c771c7be640a17ca184daf2f80fca12f7e" + } + }, + "bscSource": { + "tx": { + "hash": "0x8756c468acb8fd1f2db3002bd396922dfd859909b9ce0cfdd263f64f31d6d5ff", + "from": "0x78b4873616922c84159188e9f7098b6d57d777c3", + "to": "0x4752397298793a7db886befc6c8e38729d269744", + "input": "0x816fa6520000000000000000000000006cfffa5bfd4277a04d83307feedfe2d18d944dd200000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000003f616c656f31396c753774636735763363376b6535676e3938683076376372736e346a6363743475636b3075307139657775687463306863397330727967647300", + "blockNumber": "58692064" + }, + "receipt": { + "transactionHash": "0x8756c468acb8fd1f2db3002bd396922dfd859909b9ce0cfdd263f64f31d6d5ff", + "blockNumber": "58692064", + "status": "success", + "logs": [ + { + "address": "0x6cfffa5bfd4277a04d83307feedfe2d18d944dd2", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000078b4873616922c84159188e9f7098b6d57d777c3", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000989680", + "blockNumber": "58692064", + "transactionHash": "0x8756c468acb8fd1f2db3002bd396922dfd859909b9ce0cfdd263f64f31d6d5ff", + "transactionIndex": 62, + "blockHash": "0xc2a12044166f97dde2d5ce29a4a3bce09f85094ef11f326c93cf3bd0a1e11f7f", + "blockTimestamp": "1756017290", + "logIndex": 280, + "removed": false + }, + { + "address": "0x397e47f5072b48681b170199551bdb7fbda136b7", + "topics": [ + "0x2ea0473a63d92d3182c86a6f05d1984a63782c7c58f5d32bb629fdf43388c1b0" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000006273633a380000000000000000000000004752397298793a7db886befc6c8e38729d26974400000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000037f91e000000000000000000000000000000000000000000000000000000616c656f3a10000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003f616c656f316572377a656a6674343576616a78723771307634346e346d77307633326163366a77773778756c6e797a70757230777579677a713261747a6e710000000000000000000000000078b4873616922c84159188e9f7098b6d57d777c30000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000051333434333834333238323331333238333335353532323537333233393038353639363930323931393835303336353231373533393336363738343733393339333231303732323334343938366669656c64000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f616c656f31396c753774636735763363376b6535676e3938683076376372736e346a6363743475636b30753071396577756874633068633973307279676473000000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "58692064", + "transactionHash": "0x8756c468acb8fd1f2db3002bd396922dfd859909b9ce0cfdd263f64f31d6d5ff", + "transactionIndex": 62, + "blockHash": "0xc2a12044166f97dde2d5ce29a4a3bce09f85094ef11f326c93cf3bd0a1e11f7f", + "blockTimestamp": "1756017290", + "logIndex": 281, + "removed": false + } + ] + } + } +} diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py index 0a9dc9b8..50740068 100644 --- a/bridge-sdk/tests/live/cases.py +++ b/bridge-sdk/tests/live/cases.py @@ -65,6 +65,10 @@ def private_mint(self) -> bool: CASES: dict[str, CaseSpec] = { + "aleo-verulink": CaseSpec(name="aleo-verulink", protocol="verulink", source_family="aleo", amount="20", mode="public", + veil_source="mainnet/verulink-readonly.live.test.ts"), + "evm-verulink": CaseSpec(name="evm-verulink", protocol="verulink", source_family="evm", amount="20", + veil_source="mainnet/verulink-readonly.live.test.ts"), "evm-cctp": CaseSpec(name="evm-cctp", protocol="cctp", source_family="evm", amount="2", veil_source="mainnet/cctp-roundtrip.live.test.ts"), "evm-hyperlane": CaseSpec( @@ -182,6 +186,9 @@ def print_quote(bridge: Any, quote: Any, *, case: str, route_id: str, log: Calla log(f" mint mode {plan.mint_mode}") for fee in quote.fees: estimated = " (estimated)" if fee.estimated else "" + if fee.amount is None: + log(f" fee unpriced {bridge.registry.chain(fee.chain_id).native_symbol} [{fee.kind}]") + continue log(f" fee {fee.amount} {bridge.registry.asset(fee.asset_id).symbol} [{fee.kind}]{estimated}") # An EvmXReserveQuote carries no `fees`: its protocol cost is the max fee the deposit authorizes. max_fee = getattr(quote, "max_fee_atomic", None) @@ -256,7 +263,7 @@ 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": + elif quote.kind in ("evm-cctp", "aleo-verulink", "evm-verulink"): _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 @@ -326,7 +333,7 @@ def _drive(bridge: Any, progress: Any, state: LiveState, state_path: Path, *, sp benchmark.mark("resume-returned") continue if progress.next == "complete": - if not secret_nonce: + if spec.private_mint and not secret_nonce: raise LiveCaseError( "The private mint needs the secret nonce kept beside this case's state file; " "it is missing, so the mint cannot be completed here") @@ -369,7 +376,7 @@ def completion_has_no_destination_id(route: Route, registry: Registry) -> bool: id nor a destination transaction id — veil's ``aleo-hyperlane.live.test.ts:163`` likewise asserts only ``completed`` and ``sourceTxId`` there (the 2026-09-22 mainnet ``aleo/eth->ethereum/eth`` leg completed exactly this way, +1 wei on Ethereum).""" - return registry.chain(registry.asset(route.source_asset_id).chain_id).family == "aleo" + return route.protocol != "verulink" and registry.chain(registry.asset(route.source_asset_id).chain_id).family == "aleo" def delivery_is_a_balance_rise(route: Route, registry: Registry) -> bool: @@ -579,6 +586,8 @@ def run(bridge: Any, route_id: str, *, state_path: Path | str, recipient: str | run_solana_hyperlane = _runner("solana-hyperlane") RUNNERS: dict[str, Callable[..., LiveState]] = { + "aleo-verulink": _runner("aleo-verulink"), + "evm-verulink": _runner("evm-verulink"), "evm-hyperlane": run_evm_hyperlane, "evm-xreserve": run_evm_xreserve, "aleo-hyperlane": run_aleo_hyperlane, diff --git a/bridge-sdk/tests/live/config.py b/bridge-sdk/tests/live/config.py index 473d4b76..0516dcc0 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-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") +CASE_NAMES = ("aleo-verulink", "evm-verulink", "evm-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") FUNDS_VAR = "BRIDGE_LIVE_FUNDS" STATE_DIR_VAR = "BRIDGE_LIVE_STATE_DIR" @@ -68,9 +68,10 @@ "arc": ("ARC_RPC_URL", "BRIDGE_LIVE_ARC_RPC_URL"), "base": ("BASE_RPC_URL", "BRIDGE_LIVE_BASE_RPC_URL"), "arbitrum": ("ARBITRUM_RPC_URL", "BRIDGE_LIVE_ARBITRUM_RPC_URL"), + "bsc": ("BSC_RPC_URL", "BRIDGE_LIVE_BSC_RPC_URL"), } DEFAULT_EVM_CHAIN_RPC_URL = {"arc": DEFAULT_ARC_RPC_URL, "base": DEFAULT_BASE_RPC_URL, - "arbitrum": DEFAULT_ARBITRUM_RPC_URL} + "arbitrum": DEFAULT_ARBITRUM_RPC_URL, "bsc": "https://bsc-dataseed.bnbchain.org"} #: The Aleo network name each bridge environment runs on. ALEO_NETWORKS = {"mainnet": "mainnet", "testnet": "testnet"} diff --git a/bridge-sdk/tests/live/test_lifecycle_live.py b/bridge-sdk/tests/live/test_lifecycle_live.py index 2008a4dd..0136fa9c 100644 --- a/bridge-sdk/tests/live/test_lifecycle_live.py +++ b/bridge-sdk/tests/live/test_lifecycle_live.py @@ -281,6 +281,15 @@ def _testnet(case: str, route_id: str, make_bridge: Callable[[], Any], # ── the five mainnet cases, one test each, over every route they cover ─────── +@_params("aleo-verulink") +def test_aleo_verulink(route, mainnet_client, record_property): + _mainnet("aleo-verulink", route, mainnet_client, record_property) + + +@_params("evm-verulink") +def test_evm_verulink(route, mainnet_client, record_property): + _mainnet("evm-verulink", route, mainnet_client, record_property) + @_params("evm-hyperlane") def test_evm_hyperlane(route, mainnet_client, record_property): """veil mainnet/evm-hyperlane.live.test.ts: ethereum → aleo over the Hyperlane warp routes.""" diff --git a/bridge-sdk/tests/live/test_verulink_readonly.py b/bridge-sdk/tests/live/test_verulink_readonly.py new file mode 100644 index 00000000..8122ff78 --- /dev/null +++ b/bridge-sdk/tests/live/test_verulink_readonly.py @@ -0,0 +1,28 @@ +"""Read deployment, pause state, fee rates and limits without accounts or writes.""" +import os + +import pytest + +from aleo import Aleo, HTTPProvider +from aleo_bridge import Bridge, Ethereum + +pytestmark = [pytest.mark.live, pytest.mark.skipif( + os.environ.get("BRIDGE_VERULINK_READ_ONLY") != "1", + reason="set BRIDGE_VERULINK_READ_ONLY=1 for public deployment reads")] + + +@pytest.mark.parametrize("source,destination,recipient", [ + ("aleo", "bsc", "0x734C0a5AB55885974cEDb9D6ff71d8E8448c7375"), + ("bsc", "aleo", "aleo1k5a999p502ty77ur8yryj7qmj2rtxyl8jylpnkcnfuvgyt0vxvyssyn69g"), +]) +def test_public_verulink_quote(source, destination, recipient): + aleo = Aleo(HTTPProvider(os.environ.get("ALEO_RPC_URL", "https://edge.provable.com/api"), network="mainnet")) + bsc = Ethereum(os.environ.get("BSC_RPC_URL", "https://bsc-dataseed.bnbchain.org")) + bridge = Bridge(aleo, evm={"bsc": bsc}) + quote = bridge.quote(source_chain=source, source_asset="aleo", destination_chain=destination, + amount="20", recipient=recipient) + assert quote.amount_atomic == 20_000_000 + assert quote.amount_out and 0 <= quote.fee_atomic < quote.amount_atomic + assert quote.plan.amount_out == quote.amount_out + assert aleo.default_account is None and bsc.address is None + print(source, "->", destination, "rate:", quote.rate, "received:", quote.amount_out) diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py index 196f1dd0..4bff15f1 100644 --- a/bridge-sdk/tests/test_agent.py +++ b/bridge-sdk/tests/test_agent.py @@ -536,8 +536,16 @@ def test_pause_status_tool_reports_switches_and_write_errors_name_the_fix(): assert "bridge_pause_status" in {t["name"] for t in bridge_tools(include_writes=False)} -def test_existing_tool_schemas_are_byte_identical_to_the_snapshot(): +def test_existing_tool_schemas_preserve_snapshot_with_additive_protocol_choices(): snap = {t["name"]: t for t in json.loads((Path(__file__).parent / "fixtures" / "agent-tools-2026-10-30.json").read_text())} + for name in ("bridge_quote", "bridge_execute"): + snap[name]["input_schema"]["properties"]["bridge_protocol"]["enum"].append("verulink") + snap["bridge_list_routes"]["input_schema"]["properties"]["bridge_protocol"]["enum"].extend(["cctp", "verulink"]) + snap["bridge_list_routes"]["description"] = ( + "Supported directions and their protocol (xreserve = USDC<->USDCx; " + "hyperlane = ETH/WBTC/USDT/SOL/BAT/USDG/ZEC; cctp = native USDC; " + "verulink = public ALEO between Aleo and BSC with a destination claim). " + "Active routes move funds; metadata-required ones are listed but refused by quote/execute.") now = {t["name"]: t for t in bridge_tools()} assert set(now) - set(snap) == {"bridge_pause_status"} for name, tool in snap.items(): diff --git a/bridge-sdk/tests/test_arc_xreserve.py b/bridge-sdk/tests/test_arc_xreserve.py index 9e0db487..58e9f9d5 100644 --- a/bridge-sdk/tests/test_arc_xreserve.py +++ b/bridge-sdk/tests/test_arc_xreserve.py @@ -42,7 +42,7 @@ def test_minimum_burn_even_when_fee_is_smaller(): def test_quote_uses_live_fee_and_selected_route(): - b = make_bridge() + b = make_bridge(evm={'arc': Ethereum(w3=fake_web3(chain_id=5042))}) 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' @@ -51,14 +51,14 @@ def test_quote_uses_live_fee_and_selected_route(): @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 = make_bridge(evm={'arc': Ethereum(w3=fake_web3(chain_id=5042))}) 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 = make_bridge(evm={'arc': Ethereum(w3=fake_web3(chain_id=5042))}) b.xreserve.circle_session = session = FeeSession() q = b.quote(route=OUT, amount='2', recipient='0x'+'11'*20) session.fee = '2000000' diff --git a/bridge-sdk/tests/test_cctp_quote.py b/bridge-sdk/tests/test_cctp_quote.py index aaeb4ed0..d72ff744 100644 --- a/bridge-sdk/tests/test_cctp_quote.py +++ b/bridge-sdk/tests/test_cctp_quote.py @@ -4,9 +4,10 @@ import pytest from aleo_bridge.errors import BridgeError +from aleo_bridge import Ethereum from aleo_bridge.lifecycle import prepare from aleo_bridge.registry import DEFAULT_REGISTRY -from tests.fakes.fake_web3 import make_bridge +from tests.fakes.fake_web3 import fake_web3, make_bridge ROUTES = [f'cctp:{a}/usdc->{b}/usdc' for other in ('ethereum', 'base', 'arbitrum') for a, b in ((other, 'arc'), ('arc', other))] @@ -32,7 +33,8 @@ def json(self): def bridge(): - b = make_bridge() + b = make_bridge(evm={chain: Ethereum(w3=fake_web3(chain_id=chain_id)) + for chain, chain_id in {"ethereum": 1, "arc": 5042, "base": 8453, "arbitrum": 42161}.items()}) b.cctp.circle_session = CircleSession() return b diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index e4e762f3..2b6a1681 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) == 40 and routes[0] == "xreserve:ethereum/usdc->aleo/usdcx" + assert len(routes) == 42 and routes[0] == "xreserve:ethereum/usdc->aleo/usdcx" assert cli.main(["assets"]) == 0 - assert len(json.loads(capsys.readouterr().out)) == 30 + assert len(json.loads(capsys.readouterr().out)) == 31 assert cli.main(["bogus"]) == 2 diff --git a/bridge-sdk/tests/test_evm_connections.py b/bridge-sdk/tests/test_evm_connections.py index 4d48e57d..36b9b6e2 100644 --- a/bridge-sdk/tests/test_evm_connections.py +++ b/bridge-sdk/tests/test_evm_connections.py @@ -10,6 +10,22 @@ KEY = '0x' + '11' * 32 +def test_bsc_connection_handles_poa_blocks_and_shared_web3(): + from web3 import Web3 + from web3.providers.base import BaseProvider + + class Provider(BaseProvider): + def make_request(self, method, params): + assert method == "eth_getBlockByNumber" + return {"jsonrpc": "2.0", "id": 1, "result": {"extraData": "0x" + "ab" * 97}} + + w3 = Web3(Provider()) + connection = Ethereum(w3=w3) + for _ in range(2): + b = make(evm={"bsc": connection}) + assert len(b.evm("bsc").conn.w3.eth.get_block("latest")["proofOfAuthorityData"]) == 97 + + def make(**kwargs): return Bridge(FakeAleo(mappings=default_mappings()), **kwargs) diff --git a/bridge-sdk/tests/test_hyperlane.py b/bridge-sdk/tests/test_hyperlane.py index 96d69cf1..bd17521d 100644 --- a/bridge-sdk/tests/test_hyperlane.py +++ b/bridge-sdk/tests/test_hyperlane.py @@ -253,6 +253,16 @@ def test_two_destination_assets_are_selected_by_route_through_tier1_and_tier2(br ambiguous. quote()/execute() go by the resolved route; Tier-2 takes the Route or its id.""" route = REG.route(route_id) source = REG.asset(route.source_asset_id) + from base64 import b64decode + from aleo_bridge import Bridge, Ethereum, Solana + from tests.fakes.fake_solana import FakeSolanaClient + from tests.fakes.fake_web3 import fake_web3 + # Pause checks also read the destination. Keep both destination transports local. + pda = json.loads((Path(__file__).parent / "fixtures" / "sealevel-token-pda-bat.json").read_text()) + accounts = {REG.route(f"hyperlane:solana/{symbol}->aleo/{symbol}").meta_str("tokenPda"): b64decode(pda["data_base64"]) + for symbol in ("bat", "usdg")} + bridge = Bridge(bridge.aleo, ethereum=Ethereum(w3=fake_web3()), + solana=Solana(client=FakeSolanaClient(accounts=accounts))) amount = "0.0001" quote = bridge.quote(route=route_id, amount=amount, recipient=recipient) assert quote.kind == "aleo-hyperlane" and quote.plan.route_id == route_id and quote.payment_microcredits > 0 diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py index 08dc79ba..309af6c4 100644 --- a/bridge-sdk/tests/test_live_helpers.py +++ b/bridge-sdk/tests/test_live_helpers.py @@ -115,7 +115,7 @@ def test_gates_only_read_the_environment(monkeypatch): def test_case_names_include_cctp(): - assert live_config.CASE_NAMES == ("evm-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", + assert live_config.CASE_NAMES == ("aleo-verulink", "evm-verulink", "evm-cctp", "evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") @@ -964,7 +964,8 @@ def test_arc_base_and_arbitrum_rpc_urls_default_to_public_providers(monkeypatch) assert live_config.evm_chain_rpc_url("arc") == "https://rpc.mainnet.arc.io" assert live_config.evm_chain_rpc_url("base") == "https://base-rpc.publicnode.com" assert live_config.evm_chain_rpc_url("arbitrum") == "https://arbitrum-one-rpc.publicnode.com" - assert set(live_config.EVM_CHAIN_RPC_VARS) == set(live_config.DEFAULT_EVM_CHAIN_RPC_URL) == {"arc", "base", "arbitrum"} + assert live_config.evm_chain_rpc_url("bsc") == "https://bsc-dataseed.bnbchain.org" + assert set(live_config.EVM_CHAIN_RPC_VARS) == set(live_config.DEFAULT_EVM_CHAIN_RPC_URL) == {"arc", "base", "arbitrum", "bsc"} monkeypatch.setenv("ARC_RPC_URL", "https://arc.example") monkeypatch.setenv("BRIDGE_LIVE_BASE_RPC_URL", "https://base.example") diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py index b97f35fe..e30f4525 100644 --- a/bridge-sdk/tests/test_registry.py +++ b/bridge-sdk/tests/test_registry.py @@ -20,20 +20,20 @@ def test_shape_and_version(): - assert REG.version == "2026-09-30.hyperlane-bat-usdg-zec.1" - assert len(REG.chains()) == 9 and len(REG.assets()) == 30 - assert len(REG.routes(include_unavailable=True)) == 40 - assert len(REG.routes()) == 40 # nothing is 'disabled' in this snapshot; metadata-required stays visible + assert REG.version == "2026-11-04.verulink-bsc.1" + assert len(REG.chains()) == 10 and len(REG.assets()) == 31 + assert len(REG.routes(include_unavailable=True)) == 42 + assert len(REG.routes()) == 42 # 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", "arc", "solana", "base", "arbitrum", "hyperevm", "aleo-testnet", "sepolia"] + assert [c.id for c in REG.chains()] == ["aleo", "ethereum", "arc", "solana", "base", "arbitrum", "hyperevm", "aleo-testnet", "sepolia", "bsc"] 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 aleo.protocol_domains == {"xreserve": 10002, "hyperlane": 1634493807, "verulink": 6694886634401} 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 == {"cctp": 6} and REG.chain("hyperevm").native_symbol == "HYPE" @@ -69,7 +69,7 @@ def test_assets_and_lookups(): # re.fullmatch, not re.search: a trailing newline must not sneak past the "$" anchor assert not REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001\n") assert [a.id for a in REG.assets(chain="aleo")] == ["aleo/aleo", "aleo/usdcx", "aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol", "aleo/bat", "aleo/usdg", "aleo/zec", "aleo/usad"] - assert [a.id for a in REG.assets(symbol="aleo")] == ["aleo/aleo", "ethereum/aleo", "solana/aleo", "base/aleo", "hyperevm/aleo"] + assert [a.id for a in REG.assets(symbol="aleo")] == ["aleo/aleo", "ethereum/aleo", "solana/aleo", "base/aleo", "hyperevm/aleo", "bsc/aleo"] assert [a.id for a in REG.assets(environment="testnet")] == ["aleo-testnet/usdcx", "sepolia/usdc"] for bad in ("aleo/doge", "doge", ("aleo", "doge"), "a/b/c"): with pytest.raises(RouteNotFoundError): @@ -81,7 +81,7 @@ def test_usdcx_only_via_xreserve_and_others_via_hyperlane(): assert usdcx and all(r.protocol == "xreserve" for r in usdcx) for symbol in ("ETH", "WBTC", "USDT", "SOL", "BAT", "USDG", "ZEC", "ALEO", "USAD"): routes = REG.routes(symbol=symbol, include_unavailable=True) - assert routes and all(r.protocol == "hyperlane" for r in routes), symbol + assert routes and all(r.protocol == ("verulink" if "bsc/" in r.id else "hyperlane") for r in routes), symbol def test_xreserve_routes(): @@ -248,7 +248,7 @@ 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", "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")) == 38 + assert len(REG.routes(environment="testnet")) == 2 and len(REG.routes(environment="mainnet")) == 40 assert len(REG.routes(source_chain="solana")) == 5 # SOL + BAT/USDG/ZEC deposits + metadata-required ALEO assert len(REG.routes(source_chain="SOLANA", destination_chain="Aleo")) == 5 # case-insensitive # the asset filters narrow a chain pair to one asset on either side @@ -345,7 +345,7 @@ def test_validation_failures(): def test_data_module_is_plain_literals(): assert data.REGISTRY_VERSION == REG.version - assert len(data.CHAINS) == 9 and len(data.ASSETS) == 30 and len(data.ROUTES) == 40 + assert len(data.CHAINS) == 10 and len(data.ASSETS) == 31 and len(data.ROUTES) == 42 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_verulink.py b/bridge-sdk/tests/test_verulink.py new file mode 100644 index 00000000..290b5dbc --- /dev/null +++ b/bridge-sdk/tests/test_verulink.py @@ -0,0 +1,69 @@ +"""Public Verulink routes and fee/peer validation (Veil PR 217).""" +import pytest + +from aleo_bridge import BridgeError, DEFAULT_REGISTRY, PauseCheckError, RoutePausedError, prepare + +ALEO = "aleo19lu7tcg5v3c7ke5gn98h0v7crsn4jcct4uck0u0q9ewuhtc0hc9s0rygds" +EVM = "0x78b4873616922c84159188e9f7098b6d57d777c3" + + +@pytest.mark.parametrize("source,destination,recipient,executor", [ + ("aleo", "bsc", EVM, "evm-wallet"), ("bsc", "aleo", ALEO, "aleo-wallet"), +]) +def test_public_routes_require_destination_claim(source, destination, recipient, executor): + plan = prepare(DEFAULT_REGISTRY, source_chain=source, source_asset="aleo", + destination_chain=destination, amount="20", recipient=recipient) + assert plan.protocol == "verulink" + assert plan.amount_atomic == 20_000_000 + assert [s.executor for s in plan.steps if s.id == "destination-claim"] == [executor] + assert sum(s.irreversible for s in plan.steps) == 1 + + +@pytest.mark.parametrize("mode", ["record", "private"]) +def test_no_private_verulink_delivery(mode): + with pytest.raises(BridgeError, match="xReserve"): + prepare(DEFAULT_REGISTRY, source_chain="bsc", source_asset="aleo", destination_chain="aleo", + amount="20", recipient=ALEO, mint_mode=mode) + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_live_fee_truncation_and_net_limits(outbound): + from tests.fakes.fake_verulink import Harness + h = Harness(outbound) + q = h.quote() + assert q.amount_out == "10.989001" + assert q.fee_atomic == 11_000 + assert q.rate == 100 + assert q.plan.amount_out == q.amount_out + assert len([f for f in q.fees if f.kind == "network" and f.amount is None]) == 2 + assert not h.sent and not h.aleo.submitted + with pytest.raises(BridgeError, match="minimum"): + h.quote("10") + assert h.quote("10.01001").amount_out == "10" + + +def test_quote_rejects_wrong_chain_peers_pause_and_overflow(): + from tests.fakes.fake_verulink import Harness + h = Harness() + h.rpc.chain_id = 1 + with pytest.raises(BridgeError, match="chain 56"): + h.quote() + h.rpc.chain_id = 56 + h.rpc.values["paused"] = True + with pytest.raises(BridgeError, match="paused"): + h.quote() + h.rpc.values["paused"] = False + with pytest.raises(BridgeError, match="u64"): + h.quote("184467440737.095517") + h.rpc.values["erc20Bridge"] = "0x" + "11" * 20 + with pytest.raises(BridgeError, match="peers"): + h.quote() + + +@pytest.mark.parametrize("value", [None, "true", "0u8"]) +def test_missing_or_malformed_aleo_active_flag_fails_closed(value): + from tests.fakes.fake_verulink import Harness, M + h = Harness() + h.aleo.mappings[M["aleoBridge"]]["bridge_settings"]["3u8"] = value + with pytest.raises(RoutePausedError if value == "0u8" else PauseCheckError): + h.quote() diff --git a/bridge-sdk/tests/test_verulink_example.py b/bridge-sdk/tests/test_verulink_example.py new file mode 100644 index 00000000..bfd4fb44 --- /dev/null +++ b/bridge-sdk/tests/test_verulink_example.py @@ -0,0 +1,144 @@ +"""Round-trip retries must use the saved transfer, never a second source send.""" +import importlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from aleo_bridge import BridgeError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples")) + + +def example(): + return importlib.import_module("verulink_roundtrip") + + +def options(path, **extra): + return dict(aleo_address="aleo-address", bsc_address="0xaddress", amount="20", + state_path=path, **extra) + + +def test_preview_quotes_both_legs_without_a_journal(tmp_path): + module, bridge = example(), Mock() + bridge.quote.side_effect = [SimpleNamespace(amount_out="19.98"), SimpleNamespace(amount_out="19.96002")] + module.run_roundtrip(bridge, **options(tmp_path / "journey.json")) + assert bridge.quote.call_args_list[1].kwargs["amount"] == "19.98" + bridge.execute.assert_not_called() + assert not list(tmp_path.iterdir()) + + +def test_execution_requires_explicit_ack_before_wallet_loading(monkeypatch, tmp_path): + module = example() + monkeypatch.delenv("BRIDGE_LIVE_MAINNET_EXECUTE", raising=False) + build = Mock() + monkeypatch.setattr(module, "build_bridge", build) + with pytest.raises(BridgeError, match="BRIDGE_LIVE_MAINNET_EXECUTE"): + module.main(["--aleo-address", "aleo-address", "--bsc-address", "0xaddress", + "--state", str(tmp_path / "journey.json"), "--execute"]) + build.assert_not_called() + + +def test_uncertain_submission_cannot_be_executed_again(monkeypatch, tmp_path): + module, bridge = example(), Mock() + monkeypatch.setenv("BRIDGE_LIVE_MAINNET_EXECUTE", module.ACK) + bridge.aleo_address.return_value = "aleo-address" + bridge.evm.return_value.conn.require_address.return_value = "0xaddress" + bridge.evm.return_value.conn.w3.eth.get_balance.return_value = 10**18 + bridge.quote.return_value = SimpleNamespace(amount_out="19.98", plan=object()) + bridge.execute.side_effect = RuntimeError("uncertain") + path = tmp_path / "journey.json" + with pytest.raises(RuntimeError, match="uncertain"): + module.run_roundtrip(bridge, **options(path, execute=True)) + assert json.loads(path.read_text())["legs"][0]["source_attempted"] + with pytest.raises(BridgeError, match="uncertain"): + module.run_roundtrip(bridge, **options(path, execute=True)) + assert bridge.execute.call_count == 1 + assert not path.with_suffix(".json.lock").exists() + + +def test_existing_lock_prevents_execution_and_remains_owned(monkeypatch, tmp_path): + module, bridge = example(), Mock() + monkeypatch.setenv("BRIDGE_LIVE_MAINNET_EXECUTE", module.ACK) + path = tmp_path / "journey.json" + lock = path.with_suffix(".json.lock") + lock.write_text("other-process") + with pytest.raises(BridgeError, match="lock"): + module.run_roundtrip(bridge, **options(path, execute=True)) + bridge.execute.assert_not_called() + assert lock.read_text() == "other-process" + + +def test_ambiguous_claim_needs_transaction_hash(monkeypatch, tmp_path): + module, bridge = example(), Mock() + monkeypatch.setenv("BRIDGE_LIVE_MAINNET_EXECUTE", module.ACK) + bridge.aleo_address.return_value = "aleo-address" + bridge.evm.return_value.conn.require_address.return_value = "0xaddress" + path = tmp_path / "journey.json" + state = {"version": 1, "aleo": "aleo-address", "bsc": "0xaddress", "amount": "20", + "legs": [{"amount": "20", "source_attempted": True, "claim_attempted": True, + "checkpoint": {"source": {"transactionId": "source"}}}]} + path.write_text(json.dumps(state)) + bridge.recover.return_value = SimpleNamespace(next="complete", receipt=SimpleNamespace( + source_tx_id="source", destination_tx_id=None), plan=SimpleNamespace( + route_id=module.ROUTES[0], amount="20", amount_atomic=20_000_000, sender="aleo-address", recipient="0xaddress")) + with pytest.raises(BridgeError, match="Claim submission is uncertain"): + module.run_roundtrip(bridge, **options(path, execute=True)) + bridge.execute.assert_not_called() + bridge.complete.assert_not_called() + + +def test_copied_checkpoint_cannot_authorize_a_claim(monkeypatch, tmp_path): + module, bridge = example(), Mock() + monkeypatch.setenv("BRIDGE_LIVE_MAINNET_EXECUTE", module.ACK) + bridge.aleo_address.return_value = "aleo-address" + bridge.evm.return_value.conn.require_address.return_value = "0xaddress" + path = tmp_path / "journey.json" + path.write_text(json.dumps({"version": 1, "aleo": "aleo-address", "bsc": "0xaddress", "amount": "20", + "legs": [{"amount": "20", "source_attempted": True, "checkpoint": {"source": {"transactionId": "other"}}}]})) + bridge.recover.return_value = SimpleNamespace(next="complete", plan=SimpleNamespace( + route_id=module.ROUTES[0], amount="20", amount_atomic=20_000_000, sender="aleo-address", recipient="different-recipient"), + receipt=SimpleNamespace(source_tx_id="other", destination_tx_id=None)) + with pytest.raises(BridgeError, match="checkpoint differs"): + module.run_roundtrip(bridge, **options(path, execute=True)) + bridge.complete.assert_not_called() + bridge.execute.assert_not_called() + + +def test_roundtrip_uses_received_amount_and_reverifies_completed_legs(monkeypatch, tmp_path): + from aleo_bridge import DEFAULT_REGISTRY, Receipt, Status, create_checkpoint, prepare, to_progress + from tests.test_verulink import ALEO, EVM + module, bridge = example(), Mock() + monkeypatch.setenv("BRIDGE_LIVE_MAINNET_EXECUTE", module.ACK) + bridge.registry = DEFAULT_REGISTRY + bridge.aleo_address.return_value = ALEO + bridge.evm.return_value.conn.require_address.return_value = EVM + bridge.evm.return_value.conn.w3.eth.get_balance.return_value = 10**18 + completed = {} + + def quote(**kwargs): + plan = prepare(DEFAULT_REGISTRY, **kwargs) + return SimpleNamespace(plan=plan, amount_out="19.98" if plan.route_id == module.ROUTES[0] else "19.96002") + + def execute(plan, *, on_checkpoint, **kwargs): + outbound = plan.route_id == module.ROUTES[0] + r = Receipt("transfer-" + str(outbound), "verulink", Status.COMPLETED, + source_tx_id="source-" + str(outbound), destination_tx_id="claim-" + str(outbound), + protocol_state={"routeId": plan.route_id, "packet": {"amount": "19980000" if outbound else "19960020"}}) + cp = create_checkpoint(plan, r, DEFAULT_REGISTRY) + completed[cp.id] = to_progress(plan, r) + on_checkpoint(cp) + return completed[cp.id] + + bridge.quote.side_effect = quote + bridge.execute.side_effect = execute + bridge.recover.side_effect = lambda cp: completed[cp["receiptId"]] + args = dict(aleo_address=ALEO, bsc_address=EVM, amount="20.0", state_path=tmp_path / "journey.json", execute=True) + module.run_roundtrip(bridge, **args) + assert bridge.execute.call_count == 2 + assert bridge.execute.call_args_list[1].args[0].amount == "19.98" + module.run_roundtrip(bridge, **args) + assert bridge.execute.call_count == 2 and bridge.recover.call_count == 4 diff --git a/bridge-sdk/tests/test_verulink_lifecycle.py b/bridge-sdk/tests/test_verulink_lifecycle.py new file mode 100644 index 00000000..61e6f440 --- /dev/null +++ b/bridge-sdk/tests/test_verulink_lifecycle.py @@ -0,0 +1,299 @@ +"""Funds-safety tests for public Verulink execution, claims, and recovery.""" +import copy +import json +from dataclasses import replace +from types import SimpleNamespace + +import pytest +from eth_abi import encode +from eth_utils import keccak + +from aleo_bridge import BridgeError, Receipt, RoutePausedError, Status, create_checkpoint, lifecycle, to_progress, PreparedTx +from aleo_bridge._verulink_wire import address_literal, incoming, packet_hash +from tests.fakes.fake_verulink import Harness, FIXTURE, M + + +def receipt(h): + tx_id = FIXTURE["aleoSource"]["transaction"]["id"] if h.outbound else FIXTURE["bscSource"]["tx"]["hash"] + return Receipt(tx_id, "verulink", Status.SOURCE_CONFIRMING, source_tx_id=tx_id, protocol_state={"routeId": h.plan.route_id}) + + +def ready(h): + return lifecycle.get_status(h.bridge, h.plan, receipt(h)) + + +def input_(value): + return {"type": "public", "value": value} + + +def prepared_claim(h, p, signatures=None): + sigs = signatures or FIXTURE["toAleo"]["status"]["signatures"] + signers, signed = [s["signer"] for s in sigs], [s["sign"] for s in sigs] + while len(signers) < 5: + signers.append("aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc") + signed.append(signed[0]) + return {"id": "at1prepared", "execution": {"transitions": [ + {"program": "credits.aleo", "function": "transfer_public", "inputs": [input_(p["recipient"]), input_(p["amount"] + "u64")]}, + {"program": M["aleoTokenService"], "function": "token_receive_public", "outputs": [{"type": "public", "value": "true"}], + "inputs": list(map(input_, [address_literal(p["sender"]), p["recipient"], p["amount"] + "u64", + p["sequence"] + "u64", p["height"] + "u64", "[" + ", ".join(signers) + "]", "[" + ", ".join(signed) + "]", + p["sourceChain"] + "u128", address_literal(p["sourceAddress"])]))}]}} + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_attestation_then_claim_and_no_duplicate_submission(outbound, monkeypatch): + h = Harness(outbound) + r = ready(h) + assert r.status == Status.DESTINATION_ACTION_REQUIRED + if not outbound: + tx = prepared_claim(h, r.protocol_state["packet"]) + monkeypatch.setattr(lifecycle, "_prepare_aleo", lambda *args: PreparedTx(tx["id"], json.dumps(tx))) + progress = h.bridge.complete(to_progress(h.plan, r), on_checkpoint=h.saved.append) + assert progress.receipt.status == Status.DESTINATION_CONFIRMING + assert h.saved[-1].destination["transactionId"] == progress.receipt.destination_tx_id + assert len(h.sent if outbound else h.aleo.submitted) == 1 + if not outbound: + assert h.saved[0].destination["preparedTransaction"]["transactionId"] == "at1prepared" + with pytest.raises(BridgeError, match="already submitted"): + h.bridge.complete(progress) + + +@pytest.mark.parametrize("outbound", [True, False]) +@pytest.mark.parametrize("paused_side", ["source", "destination"]) +def test_claim_only_requires_destination_to_be_unpaused(outbound, paused_side, monkeypatch): + h = Harness(outbound) + r = ready(h) + if not outbound: + tx = prepared_claim(h, r.protocol_state["packet"]) + monkeypatch.setattr(lifecycle, "_prepare_aleo", lambda *args: PreparedTx(tx["id"], json.dumps(tx))) + if outbound == (paused_side == "source"): + h.aleo.mappings[M["aleoBridge"]]["bridge_settings"]["3u8"] = "0u8" + else: + h.rpc.values["paused"] = True + if paused_side == "destination": + with pytest.raises(RoutePausedError): + h.bridge.complete(to_progress(h.plan, r)) + assert not h.sent and not h.aleo.submitted + else: + progress = h.bridge.complete(to_progress(h.plan, r)) + assert progress.receipt.status == Status.DESTINATION_CONFIRMING + assert len(h.sent if outbound else h.aleo.submitted) == 1 + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_consumption_without_delivery_evidence_stays_pending(outbound): + h = Harness(outbound) + r = ready(h) + p = r.protocol_state["packet"] + if outbound: + h.rpc.values["consumedPackets"] = packet_hash(p) + else: + h.aleo.mappings[M["aleoBridge"]]["in_packet_consumed"] = { + f"{{ chain_id: {p['sourceChain']}u128, sequence: {p['sequence']}u64 }}": "true"} + result = lifecycle.get_status(h.bridge, h.plan, r) + assert result.status == Status.DELIVERY_PENDING + assert result.next_action is None + with pytest.raises(BridgeError): + h.bridge.complete(to_progress(h.plan, r)) + assert not h.sent and not h.aleo.submitted + + +@pytest.mark.parametrize("consumed", [False, True]) +def test_prepared_claim_survives_recovery_and_is_followed(consumed): + h = Harness(False) + r = ready(h) + p = r.protocol_state["packet"] + tx = prepared_claim(h, p) + r = r.replace(id=tx["id"], protocol_state={**r.protocol_state, "preparedDestinationTransaction": json.dumps(tx)}) + h.rows = [] + if consumed: + h.aleo.mappings[M["aleoBridge"]]["in_packet_consumed"] = { + f"{{ chain_id: {p['sourceChain']}u128, sequence: {p['sequence']}u64 }}": "true"} + cp = create_checkpoint(h.plan, r, h.bridge.registry) + progress = h.bridge.recover(cp) + saved = create_checkpoint(progress.plan, progress.receipt, h.bridge.registry) + assert saved.destination == cp.destination + progress = h.bridge.recover(saved) + if consumed: + h.aleo.confirmed_transactions[tx["id"]] = {"status": "accepted", "transaction": tx} + progress = h.bridge.wait(progress, timeout_seconds=0) + assert progress.next == "done" + assert progress.receipt.destination_tx_id == tx["id"] + assert not h.aleo.submitted + else: + h.rows = [FIXTURE["toAleo"]] + progress = h.bridge.wait(progress, timeout_seconds=0) + progress = h.bridge.complete(progress) + assert h.aleo.submitted == [json.dumps(tx)] + assert "preparedDestinationTransaction" not in progress.receipt.protocol_state + + +@pytest.mark.parametrize("fresh", [False, True]) +def test_forged_prepared_claim_signatures_cannot_broadcast(fresh, monkeypatch): + h = Harness(False) + r = ready(h) + signatures = copy.deepcopy(FIXTURE["toAleo"]["status"]["signatures"]) + signatures[1]["sign"] = signatures[0]["sign"] + tx = prepared_claim(h, r.protocol_state["packet"], signatures) + if fresh: + monkeypatch.setattr(lifecycle, "_prepare_aleo", lambda *args: PreparedTx(tx["id"], json.dumps(tx))) + else: + r = r.replace(id=tx["id"], protocol_state={**r.protocol_state, "preparedDestinationTransaction": json.dumps(tx)}) + with pytest.raises(BridgeError, match="signature"): + h.bridge.complete(to_progress(h.plan, r)) + assert not h.aleo.submitted + + +def test_aleo_delivery_and_holding_are_distinct(): + h = Harness(False) + r = ready(h) + p = r.protocol_state["packet"] + tx = prepared_claim(h, p) + h.aleo.confirmed_transactions[tx["id"]] = {"status": "accepted", "transaction": tx} + h.aleo.mappings[M["aleoBridge"]]["in_packet_consumed"] = { + f"{{ chain_id: {p['sourceChain']}u128, sequence: {p['sequence']}u64 }}": "true"} + r = r.replace(destination_tx_id=tx["id"]) + assert lifecycle.get_status(h.bridge, h.plan, r).status == Status.COMPLETED + tx["execution"]["transitions"][-1]["outputs"][0]["value"] = "false" + result = lifecycle.get_status(h.bridge, h.plan, r) + assert result.status == Status.FAILED + assert result.protocol_state["held"] is True + + +def test_holding_keeps_the_durable_checkpoint(tmp_path): + from aleo_bridge import FileCheckpointStore + h = Harness(False) + h.bridge.checkpoints = FileCheckpointStore(tmp_path) + r = ready(h) + p = r.protocol_state["packet"] + tx = prepared_claim(h, p) + tx["execution"]["transitions"][-1]["outputs"][0]["value"] = "false" + h.aleo.confirmed_transactions[tx["id"]] = {"status": "accepted", "transaction": tx} + h.aleo.mappings[M["aleoBridge"]]["in_packet_consumed"] = { + f"{'{'} chain_id: {p['sourceChain']}u128, sequence: {p['sequence']}u64 {'}'}": "true"} + r = r.replace(destination_tx_id=tx["id"], status=Status.DESTINATION_CONFIRMING, next_action=None) + cp = create_checkpoint(h.plan, r, h.bridge.registry) + h.bridge.checkpoints.save(cp) + progress = h.bridge.wait(to_progress(h.plan, r), timeout_seconds=0) + assert progress.next == "failed" and progress.receipt.protocol_state["held"] + stored = h.bridge.checkpoints.load(cp.id) + assert stored is not None + assert stored.source["transactionId"] == r.source_tx_id + assert stored.destination["transactionId"] == tx["id"] + + +def test_bsc_finality_and_exact_recipient_mint(): + h = Harness() + r = ready(h) + p = r.protocol_state["packet"] + tx_id = "0x" + "ab" * 32 + h.rpc.values["consumedPackets"] = packet_hash(p) + consume = {"address": M["evmBridge"], "topics": [keccak(text="Consumed(uint256,uint256,bytes32,uint8)")], + "data": "0x" + encode(["uint256", "uint256", "bytes32", "uint8"], + [int(p["sourceChain"]), int(p["sequence"]), packet_hash(p), 3]).hex()} + mint = {"address": M["evmToken"], "topics": [keccak(text="Transfer(address,address,uint256)"), + bytes(32), bytes.fromhex(p["recipient"][2:]).rjust(32, b"\0")], + "data": "0x" + encode(["uint256"], [int(p["amount"])]).hex()} + for i, log in enumerate((consume, mint)): + log.update(logIndex=hex(i), transactionIndex="0x0", transactionHash=tx_id, blockHash="0x" + "bb" * 32, blockNumber="0x64") + h.rpc.receipts[tx_id] = {"status": "0x1", "transactionHash": tx_id, "blockNumber": "0x64", "logs": [consume, mint]} + h.rpc.transactions[tx_id] = {"hash": tx_id, "to": M["evmTokenService"], "from": p["recipient"], "blockNumber": "0x64", + "input": h.rpc.codec.encode_abi("tokenReceive", args=[incoming(p), b""])} + r = r.replace(destination_tx_id=tx_id) + h.rpc.height = 100 + assert lifecycle.get_status(h.bridge, h.plan, r).status == Status.DESTINATION_CONFIRMING + h.rpc.height = 114 + assert lifecycle.get_status(h.bridge, h.plan, r).status == Status.COMPLETED + h.rpc.receipts[tx_id]["logs"] = [consume] + with pytest.raises(BridgeError, match="delivery"): + lifecycle.get_status(h.bridge, h.plan, r) + h.rpc.receipts[tx_id]["logs"] = [consume, mint] + mint["topics"][2] = bytes.fromhex(h.rpc.values["holding"][2:]).rjust(32, b"\0") + result = lifecycle.get_status(h.bridge, h.plan, r) + assert result.status == Status.FAILED and result.protocol_state["held"] + + +def test_evm_source_approval_timeout_and_ambiguous_resume(): + h = Harness(False) + q = h.quote() + h.rpc.values["allowance"] = 0 + progress = h.bridge.execute(q.plan, on_checkpoint=h.saved.append, timeout_seconds=0) + assert progress.receipt.status == Status.SOURCE_APPROVAL_PENDING + assert len(h.sent) == 1 + assert h.rpc.codec.decode_function_input(h.sent[0]["data"])[0].fn_name == "approve" + r = progress.receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + with pytest.raises(BridgeError, match="cannot safely resubmit"): + h.bridge.resume(to_progress(progress.plan, r)) + assert len(h.sent) == 1 + + +def test_evm_source_burn_and_changed_fee_rejected(): + h = Harness(False) + q = h.quote() + h.rpc.values["platformFees"] = 200 + with pytest.raises(BridgeError, match="fee increased"): + h.bridge.execute(q.plan) + assert not h.sent + h.rpc.values["platformFees"] = 100 + progress = h.bridge.execute(q.plan, on_checkpoint=h.saved.append) + assert progress.receipt.status == Status.SOURCE_CONFIRMING + fn, args = h.rpc.codec.decode_function_input(h.sent[0]["data"]) + assert fn.fn_name == "publicTokenSend" and args["amount"] == 11_000_001 + assert h.saved[-1].intent["amountOut"] == "10.989001" + assert h.saved[-1].source["transactionId"] == progress.receipt.source_tx_id + + +def test_prepared_source_checkpoint_precedes_broadcast_and_resume_is_identical(monkeypatch): + h = Harness() + q = h.quote() + tx = copy.deepcopy(FIXTURE["aleoSource"]["transaction"]) + tx["id"] = "at1prepared" + transitions = tx["execution"]["transitions"] + root = transitions[-1] + root["inputs"][1]["value"] = "11000001u64" + root["inputs"][5]["value"] = "11000u64" + publish = next(t for t in transitions if t["program"] == M["aleoBridge"] and t["function"] == "publish") + publish["inputs"][6]["value"] = "10989001u128" + monkeypatch.setattr(lifecycle, "_prepare_aleo", lambda *args: PreparedTx(tx["id"], json.dumps(tx))) + def checkpoint(cp): + h.saved.append(cp) + if cp.source.get("preparedTransaction"): + assert not h.aleo.submitted + raise RuntimeError("interrupted before broadcast") + with pytest.raises(RuntimeError, match="interrupted"): + h.bridge.execute(q.plan, on_checkpoint=checkpoint) + assert not h.aleo.submitted + progress = h.bridge.recover(h.saved[-1]) + assert progress.next == "resume" + assert progress.plan.amount_out == q.amount_out + progress = h.bridge.resume(progress) + assert h.aleo.submitted == [json.dumps(tx)] + assert progress.receipt.source_tx_id == "at1prepared" + + +def test_agent_bsc_quote_and_public_claim_need_no_nonce(): + from aleo_bridge.agent import bridge_tools, dispatch_tool + h = Harness(False) + result = dispatch_tool(h.bridge, "bridge_quote", {"source_chain": "bsc", "source_asset": "aleo", + "destination_chain": "aleo", "recipient": h.plan.recipient, "amount": "20", "bridge_protocol": "verulink"}) + assert "error" not in result + assert result["kind"] == "evm-verulink" + r = ready(h) + cp = create_checkpoint(h.plan, r, h.bridge.registry) + preview = dispatch_tool(h.bridge, "bridge_complete", {"checkpoint": cp.to_dict()}) + assert "error" not in preview + assert not h.aleo.submitted + schemas = {t["name"]: t["input_schema"] for t in bridge_tools()} + assert "verulink" in schemas["bridge_list_routes"]["properties"]["bridge_protocol"]["enum"] + + +def test_agent_rejects_read_only_bsc_before_execution(): + from aleo_bridge import dispatch_tool + h = Harness(False) + h.evm.w3.eth.default_account = None + result = dispatch_tool(h.bridge, "bridge_execute", { + "source_chain": "bsc", "source_asset": "aleo", "destination_chain": "aleo", + "recipient": h.plan.recipient, "amount": "20", "bridge_protocol": "verulink", "confirm": True}) + assert result["error_type"] == "ConfigurationError" and "read-only" in result["error"] + assert "BSC_RPC_URL" in result["how_to_fix"] + assert not h.sent and not h.aleo.submitted diff --git a/bridge-sdk/tests/test_verulink_pause.py b/bridge-sdk/tests/test_verulink_pause.py new file mode 100644 index 00000000..a07a4750 --- /dev/null +++ b/bridge-sdk/tests/test_verulink_pause.py @@ -0,0 +1,74 @@ +"""Verulink routes compose with the shared pause gate after PR 86.""" +import pytest +import requests + +from aleo_bridge import PauseCheckError, RoutePausedError +from aleo_bridge.pause import PauseReader +from tests.fakes.fake_verulink import Harness, M + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_verulink_pause_status_reads_both_chains(outbound): + h = Harness(outbound) + status = h.bridge.pause_status(h.plan) + assert len(status.switches) == 6 + assert not status.paused and not status.unknown + aleo_side = "source" if outbound else "destination" + assert len([s for s in status.switches if s.side == aleo_side and s.chain_id == "aleo"]) == 4 + assert {s.locator for s in status.switches if s.chain_id == "bsc"} == {M["evmTokenService"], M["evmBridge"]} + + +@pytest.mark.parametrize("raw,state", [(None, "unknown"), ("true", "unknown"), ("0u8", "paused")]) +def test_aleo_active_setting_is_not_a_boolean_pause_flag(raw, state): + h = Harness() + h.aleo.mappings[M["aleoBridge"]]["bridge_settings"]["3u8"] = raw + status = h.bridge.pause_status(h.plan) + assert next(s for s in status.switches if s.switch == "bridge_settings[3u8]").state == state + with pytest.raises(RoutePausedError if state == "paused" else PauseCheckError): + h.quote() + + +def test_destination_only_status_skips_source_evm_reads(): + h = Harness(False) + status = h.bridge.pause_status(h.plan, sides=("destination",)) + assert len(status.switches) == 4 + assert {s.side for s in status.switches} == {"destination"} + assert not h.rpc.calls + + +def test_agent_pause_schema_and_dispatch_include_verulink(): + from aleo_bridge import bridge_tools, dispatch_tool + h = Harness() + schemas = {t["name"]: t["input_schema"] for t in bridge_tools()} + assert "verulink" in schemas["bridge_pause_status"]["properties"]["bridge_protocol"]["enum"] + result = dispatch_tool(h.bridge, "bridge_pause_status", { + "source_chain": "aleo", "source_asset": "aleo", "destination_chain": "bsc", "bridge_protocol": "verulink"}) + assert "error" not in result + assert len(result["switches"]) == 6 + assert not result["paused"] and not result["unknown"] + + +def test_verulink_pause_reads_are_fresh_and_block_execution(): + h = Harness() + quote = h.quote() + h.rpc.values["paused"] = True + with pytest.raises(RoutePausedError): + h.bridge.execute(quote.plan) + assert not h.sent and not h.aleo.submitted + + +def test_verulink_mapping_failure_retries_and_stays_unknown(monkeypatch): + h = Harness() + original = h.bridge.mapping_value + calls = [] + + def mapping(program, name, key): + if name == "bridge_settings": + calls.append(key) + raise requests.ConnectionError("unavailable") + return original(program, name, key) + + monkeypatch.setattr(h.bridge, "mapping_value", mapping) + status = PauseReader(h.bridge, sleep=lambda _: None).status(h.bridge.registry.route(h.plan.route_id)) + assert status.unknown and len(calls) == 7 + assert not h.sent and not h.aleo.submitted diff --git a/bridge-sdk/tests/test_verulink_wire.py b/bridge-sdk/tests/test_verulink_wire.py new file mode 100644 index 00000000..b1d9099d --- /dev/null +++ b/bridge-sdk/tests/test_verulink_wire.py @@ -0,0 +1,158 @@ +"""Known-answer packets/signatures from Veil PR 217 at a2071c0b. + +Fixture source: packages/bridge/test/fixtures/verulink-mainnet.json, copied verbatim. +""" +import copy +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from aleo_bridge import BridgeError + +FIXTURE = json.loads((Path(__file__).parent / "fixtures/verulink-mainnet.json").read_text()) + + +def packet(outbound): + from aleo_bridge._verulink_wire import collector_packet + return collector_packet(FIXTURE["toBsc" if outbound else "toAleo"]) + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_real_attestor_signatures(outbound): + from aleo_bridge._verulink_attestation import verified_signatures + signatures = FIXTURE["toBsc" if outbound else "toAleo"]["status"]["signatures"] + c = SimpleNamespace(outbound=outbound, aleo_bridge="bridge.aleo", evm_bridge="0x" + "01" * 20, + network="mainnet", read=lambda *args: 3 if args[1] == "quorumRequired" else True, + mapping=lambda *args: 3 if args[1] == "bridge_settings" else False) + # Aleo's membership uses contains: even a present false entry is a member. + assert len(verified_signatures(c, packet(outbound), signatures)) == 3 + tampered = {**packet(outbound), "amount": "10000001"} + with pytest.raises(BridgeError, match="signature"): + verified_signatures(c, tampered, signatures) + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_duplicate_unknown_and_zero_quorum_rejected(outbound): + from aleo_bridge._verulink_attestation import verified_signatures + signatures = copy.deepcopy(FIXTURE["toBsc" if outbound else "toAleo"]["status"]["signatures"]) + c = SimpleNamespace(outbound=outbound, aleo_bridge="bridge.aleo", evm_bridge="0x" + "01" * 20, + network="mainnet", read=lambda *args: 3 if args[1] == "quorumRequired" else True, + mapping=lambda *args: 3 if args[1] == "bridge_settings" else True) + with pytest.raises(BridgeError, match="Duplicate"): + verified_signatures(c, packet(outbound), [signatures[0]] * 3) + c.read = lambda *args: 3 if args[1] == "quorumRequired" else False + c.mapping = lambda *args: 3 if args[1] == "bridge_settings" else None + with pytest.raises(BridgeError, match="Unknown"): + verified_signatures(c, packet(outbound), signatures) + c.read = lambda *args: 0 + c.mapping = lambda *args: 0 + with pytest.raises(BridgeError, match="quorum"): + verified_signatures(c, packet(outbound), []) + + +@pytest.mark.parametrize("value", [True, -1, 1.0, "1e2", "-1", str(2**64)]) +def test_wire_integers_reject_lossy_or_out_of_range_values(value): + from aleo_bridge._verulink_wire import decimal + with pytest.raises(BridgeError): + decimal(value) + + +def test_evm_calldata_decodes_historical_transfer(): + from web3 import Web3 + from aleo_bridge._verulink_abi import ABI + function, args = Web3().eth.contract(abi=ABI).decode_function_input(FIXTURE["bscSource"]["tx"]["input"]) + assert function.fn_name == "publicTokenSend" + assert args["tokenAddress"].lower() == "0x6cfff a5bfd4277a04d83307feedfe2d18d944dd2".replace(" ", "") + assert args["receiver"] == FIXTURE["toAleo"]["message"]["receiver"] + + +def test_old_registry_checkpoints_keep_only_preexisting_routes(): + from aleo_bridge import DEFAULT_REGISTRY + from aleo_bridge._registry_compatibility import is_registry_version_compatible + for route in DEFAULT_REGISTRY.routes(): + assert is_registry_version_compatible(DEFAULT_REGISTRY, "2026-09-30.hyperlane-bat-usdg-zec.1", route.id) is (route.protocol != "verulink") + + +@pytest.mark.parametrize("outbound", [True, False]) +def test_reconstructs_historical_source_packet(outbound): + from tests.fakes.fake_verulink import Harness + from aleo_bridge._verulink_source import source_packet + from aleo_bridge._verulink_wire import same_packet + h = Harness(outbound) + tx_id = FIXTURE["aleoSource"]["transaction"]["id"] if outbound else FIXTURE["bscSource"]["tx"]["hash"] + assert same_packet(source_packet(h.bridge.verulink.context(h.plan), h.plan, tx_id), packet(outbound)) + + +def test_unrelated_bridge_event_does_not_hide_source_packet(): + from tests.fakes.fake_verulink import Harness, M + from aleo_bridge._verulink_source import source_packet + from aleo_bridge._verulink_wire import same_packet + h = Harness(False) + tx_id = FIXTURE["bscSource"]["tx"]["hash"] + log = copy.deepcopy(h.rpc.receipts[tx_id]["logs"][0]) + log.update(address=M["evmBridge"], topics=["0x" + "ab" * 32]) + h.rpc.receipts[tx_id]["logs"].append(log) + assert same_packet(source_packet(h.bridge.verulink.context(h.plan), h.plan, tx_id), packet(False)) + + +def test_negative_and_mixed_screening_votes_never_authorize_claim(): + from eth_account import Account + from eth_account.messages import encode_defunct + from eth_utils.crypto import keccak + from aleo_bridge._verulink_attestation import verified_signatures + from aleo_bridge._verulink_wire import packet_hash + c = SimpleNamespace(outbound=True, evm_bridge="0x" + "01" * 20, + read=lambda *args: 3 if args[1] == "quorumRequired" else True) + p = packet(True) + + def signed(votes): + result = [] + for i, vote in enumerate(votes): + account = Account.from_key(bytes([i + 1]) * 32) + sign = account.sign_message(encode_defunct(keccak(packet_hash(p) + bytes([vote])))) + result.append({"signer": account.address, "sign": "0x" + sign.signature.hex()}) + return result + + with pytest.raises(BridgeError, match="negatively"): + verified_signatures(c, p, signed([2, 2, 2])) + assert verified_signatures(c, p, signed([1, 1, 2])) is None + + +def test_collector_pagination_and_packet_substitution(): + from aleo_bridge._verulink_attestation import fetch_signatures + c = SimpleNamespace(collector_url="https://collector.invalid/v2") + calls = [] + row = copy.deepcopy(FIXTURE["toBsc"]) + + def get(url, **kwargs): + calls.append(url) + page = int(url.rsplit("=", 1)[1]) + return SimpleNamespace(status_code=200, json=lambda: { + "data": {"data": [] if page == 1 else [row], "currentPage": page, "totalPages": 2}}) + + session = SimpleNamespace(get=get) + assert fetch_signatures(c, session, packet(True)) == row["status"]["signatures"] + assert len(calls) == 2 + row["message"]["amount"] = "1" + with pytest.raises(BridgeError, match="source data"): + fetch_signatures(c, session, packet(True)) + + +def test_source_validation_rejects_changed_inputs_and_ambiguous_packets(): + from tests.fakes.fake_verulink import Harness, M + from aleo_bridge._verulink_source import source_packet + h = Harness() + tx_id = FIXTURE["aleoSource"]["transaction"]["id"] + c = h.bridge.verulink.context(h.plan) + root = h.aleo.confirmed_transactions[tx_id]["transaction"]["execution"]["transitions"][-1] + root["inputs"][1]["value"] = "20000000u64" + with pytest.raises(BridgeError, match="inputs"): + source_packet(c, h.plan, tx_id) + root["inputs"][1]["value"] = "10000000u64" + maps = h.aleo.mappings[M["aleoBridge"]] + maps["sequences"][M["evmDomain"] + "u128"] = "3u64" + maps["out_packets"][f"{{ chain_id: {M['evmDomain']}u128, sequence: 2u64 }}"] = FIXTURE["aleoPacket"].replace("sequence: 1u64", "sequence: 2u64") + with pytest.raises(BridgeError, match="Ambiguous"): + source_packet(c, h.plan, tx_id)