From a2a8e4384d60cf7429107d55f7fcc2a99aba36f7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 13:59:08 -0400 Subject: [PATCH 01/13] feat(bridge): pause status types and errors --- bridge-sdk/python/aleo_bridge/__init__.py | 18 +++++----- bridge-sdk/python/aleo_bridge/errors.py | 21 ++++++++++-- bridge-sdk/python/aleo_bridge/lifecycle.py | 4 +++ bridge-sdk/python/aleo_bridge/types.py | 40 ++++++++++++++++++++-- bridge-sdk/tests/test_pause_types.py | 32 +++++++++++++++++ 5 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 bridge-sdk/tests/test_pause_types.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 4bc73771..209d33af 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -37,15 +37,15 @@ def _require_aleo_sdk() -> None: from .errors import ( # noqa: E402 AmbiguousRouteError, AttestationError, BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, DeliveryUnknownError, InsufficientBalanceError, InvalidAmountError, - InvalidRecipientError, MissingExtraError, NotResumableError, PollingTimeoutError, - RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, + InvalidRecipientError, MissingExtraError, NotResumableError, PauseCheckError, PollingTimeoutError, + RegistryVersionMismatchError, RouteNotFoundError, RoutePausedError, RouteUnavailableError, UnsupportedRouteError, ) from .registry import DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, validate_registry # noqa: E402 from .types import ( # noqa: E402 CctpOptions, EvmCctpQuote, CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, GasQuote, - MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, - to_progress, + MintReceipt, PauseStatus, PauseSwitch, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, + SolanaHyperlaneQuote, Status, Step, to_progress, ) from ._calls import AleoCall, EvmCall, SolCall # noqa: E402 from .checkpoint import (Checkpoint, CheckpointLoadResult, CheckpointProblem, CheckpointStore, FileCheckpointStore, # noqa: E402 @@ -66,14 +66,14 @@ def _require_aleo_sdk() -> None: __all__ = [ "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", "CheckpointInvalidError", "ConfigurationError", "DeliveryUnknownError", "InsufficientBalanceError", - "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", - "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", - "UnsupportedRouteError", + "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", "PauseCheckError", + "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RoutePausedError", + "RouteUnavailableError", "UnsupportedRouteError", "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", "CctpOptions", "EvmCctpQuote", "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", - "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", - "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "Fee", "GasQuote", "MintReceipt", "PauseStatus", "PauseSwitch", "Plan", "PreparedTx", "PrivacyReceipt", + "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", "ComplianceProof", "ComplianceProofs", "MerkleProofInput", "MerkleProofPair", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", diff --git a/bridge-sdk/python/aleo_bridge/errors.py b/bridge-sdk/python/aleo_bridge/errors.py index e3f379b8..c671f4f4 100644 --- a/bridge-sdk/python/aleo_bridge/errors.py +++ b/bridge-sdk/python/aleo_bridge/errors.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover - from .types import Progress, Status + from .types import PauseStatus, Progress, Status class BridgeError(Exception): @@ -50,6 +50,22 @@ class RouteUnavailableError(BridgeError): """The route exists but is metadata-required, disabled, or carries placeholder configuration.""" +class RoutePausedError(RouteUnavailableError): + """An operator pause switch on the source or destination side of the route is set; ``status`` names it.""" + + def __init__(self, message: str, *, status: "PauseStatus") -> None: + self.status = status + super().__init__(message) + + +class PauseCheckError(RouteUnavailableError): + """A pause switch could not be read after retries; the SDK never proceeds on an unreadable switch.""" + + def __init__(self, message: str, *, status: "PauseStatus") -> None: + self.status = status + super().__init__(message) + + class RegistryVersionMismatchError(BridgeError): """A plan or checkpoint was prepared against a different registry version.""" @@ -101,7 +117,8 @@ def __init__(self, message: str, *, status: "Status | str", progress: "Progress __all__ = [ "BridgeError", "ConfigurationError", "MissingExtraError", "RouteNotFoundError", "AmbiguousRouteError", - "RouteUnavailableError", "RegistryVersionMismatchError", "UnsupportedRouteError", "InvalidAmountError", + "RouteUnavailableError", "RoutePausedError", "PauseCheckError", "RegistryVersionMismatchError", + "UnsupportedRouteError", "InvalidAmountError", "InvalidRecipientError", "InsufficientBalanceError", "ChainMismatchError", "NotResumableError", "CheckpointInvalidError", "AttestationError", "DeliveryUnknownError", "PollingTimeoutError", ] diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index e710ee56..2bf6e99f 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -36,8 +36,10 @@ InvalidAmountError, InvalidRecipientError, NotResumableError, + PauseCheckError, PollingTimeoutError, RegistryVersionMismatchError, + RoutePausedError, RouteUnavailableError, UnsupportedRouteError, ) @@ -927,6 +929,8 @@ def _is_transient_error(exc: Exception) -> bool: about an actual on-chain failure, ``CheckpointInvalidError``, ``RouteUnavailableError`` — propagates immediately. """ + if isinstance(exc, (RoutePausedError, PauseCheckError)): + return False # a pause verdict may quote an HTTP status; it is a decision, not a hiccup try: import requests if isinstance(exc, requests.RequestException): diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py index 71c2534b..b523ed38 100644 --- a/bridge-sdk/python/aleo_bridge/types.py +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -331,9 +331,45 @@ class BridgeStatus: pending: list[Progress | dict] +@dataclass(frozen=True) +class PauseSwitch: + """One operator pause switch on one end of a route, as read right now (never cached).""" + chain_id: str + side: str # "source" | "destination" + locator: str # program id, contract address or account pubkey + switch: str # "pause[true]", "paused()", "remote_routers[1634493807]", ... + state: str # "open" | "paused" | "unknown" + detail: str | None = None # the raw value read, or the last error for "unknown" + + +@dataclass(frozen=True) +class PauseStatus: + """Every switch governing a route, read live; ``paused`` or ``unknown`` means the route must not be used.""" + route_id: str + switches: tuple[PauseSwitch, ...] + + @property + def paused(self) -> bool: + return any(s.state == "paused" for s in self.switches) + + @property + def unknown(self) -> bool: + return any(s.state == "unknown" for s in self.switches) + + @property + def reason(self) -> str: + for state in ("paused", "unknown"): + for s in self.switches: + if s.state == state: + detail = f" ({s.detail})" if s.detail else "" + return f"{s.switch} on {s.locator} ({s.chain_id}, {s.side} side) is {state}{detail}" + return f"every pause switch on {self.route_id} is open" + + __all__ = [ "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", - "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", - "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "CctpOptions", "EvmCctpQuote", + "Fee", "GasQuote", "MintReceipt", "PauseStatus", "PauseSwitch", "Plan", "PreparedTx", "PrivacyReceipt", + "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "CctpOptions", + "EvmCctpQuote", ] diff --git a/bridge-sdk/tests/test_pause_types.py b/bridge-sdk/tests/test_pause_types.py new file mode 100644 index 00000000..a666878e --- /dev/null +++ b/bridge-sdk/tests/test_pause_types.py @@ -0,0 +1,32 @@ +"""PauseSwitch / PauseStatus and the two pause exceptions (spec: pause-gate design, "Result type").""" +from aleo_bridge import PauseCheckError, PauseStatus, PauseSwitch, RoutePausedError, RouteUnavailableError +from aleo_bridge.lifecycle import _is_transient_error + +OPEN = PauseSwitch("aleo", "source", "hyp_warp_token_eth_v2.aleo", "pause[true]", "open", "false") +PAUSED = PauseSwitch("aleo", "source", "hyp_warp_token_eth_v2.aleo", "pause[true]", "paused", "true") +UNKNOWN = PauseSwitch("ethereum", "destination", "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", "paused()", "unknown", + "HTTP status 503 after 7 attempts") + + +def test_status_flags_and_reason(): + s = PauseStatus("hyperlane:aleo/eth->ethereum/eth", (OPEN,)) + assert not s.paused and not s.unknown + assert s.reason == "every pause switch on hyperlane:aleo/eth->ethereum/eth is open" + s = PauseStatus("r", (OPEN, PAUSED, UNKNOWN)) + assert s.paused and s.unknown + assert "hyp_warp_token_eth_v2.aleo" in s.reason and "pause[true]" in s.reason and "paused" in s.reason + s = PauseStatus("r", (OPEN, UNKNOWN)) + assert not s.paused and s.unknown and "0x8888" in s.reason and "unknown" in s.reason + + +def test_exceptions_subclass_route_unavailable_and_carry_status(): + s = PauseStatus("r", (PAUSED,)) + for cls in (RoutePausedError, PauseCheckError): + exc = cls("msg", status=s) + assert isinstance(exc, RouteUnavailableError) and exc.status is s and str(exc) == "msg" + + +def test_pause_errors_are_never_transient(): + s = PauseStatus("r", (UNKNOWN,)) + assert not _is_transient_error(PauseCheckError("pause read failed: HTTP status 503 after 7 attempts", status=s)) + assert not _is_transient_error(RoutePausedError("route paused; request failed: no", status=s)) From 2752e68df276e6a84a96f2ff534b36b73990ed38 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 13:59:56 -0400 Subject: [PATCH 02/13] feat(bridge): pause read retry loop (2 immediate, then backoff) --- bridge-sdk/python/aleo_bridge/pause.py | 60 ++++++++++++++++++++++++++ bridge-sdk/tests/test_pause_retry.py | 57 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/pause.py create mode 100644 bridge-sdk/tests/test_pause_retry.py diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py new file mode 100644 index 00000000..14fc781b --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -0,0 +1,60 @@ +"""Operator pause switches, read live on both ends of a route before any funds move. + +Every bridge deployment has a kill switch somewhere: the ``pause`` mapping on the Aleo warp and +USDCx programs, ``paused()`` on Circle's xReserve and CCTP transmitters, a ``PausableHook`` / +``PausableIsm`` inside the Ethereum Hyperlane Mailbox's hook and ISM trees, and the enrolled +remote-router table of the Solana warp program (Hyperlane's Sealevel token program has no pause +flag of its own; un-enrolling the router is how an operator stops it). ``quote``, ``execute``, +``resume`` and ``complete`` read every switch governing the route and refuse when one is set. + +Fail closed: a switch that cannot be read after the retry budget below is ``unknown`` and blocks +exactly like ``paused``. Nothing here is cached — a pause can flip between quote and execute. + +Assumption stated once: the deployed Solana warp programs match Hyperlane's published +``hyperlane-sealevel-token`` source at hyperlane-monorepo ``45c0988`` (the commit the rest of +``_sealevel.py`` derives from); they are BPF-upgradeable, so this cannot be checked on chain. +""" +from __future__ import annotations + +import time +from typing import Callable, Sequence, TypeVar + +T = TypeVar("T") + +IMMEDIATE_RETRIES = 2 # retried at once, for a blip +BACKOFF_SECONDS = (0.5, 1.0, 2.0, 4.0) # then exponential backoff; 7 attempts in all + + +class ReadFailed(Exception): + """Internal: a switch read exhausted its retries. ``attempts`` and the ``last`` transient error.""" + + def __init__(self, attempts: int, last: Exception) -> None: + self.attempts, self.last = attempts, last + super().__init__(f"{type(last).__name__}: {last} after {attempts} attempts") + + +def read_with_retry(fn: Callable[[int], T], *, sleep: Callable[[float], None] = time.sleep) -> T: + """Call ``fn(attempt)`` until it returns: transient errors (``lifecycle._is_transient_error``) are + retried ``IMMEDIATE_RETRIES`` times with no delay, then once per ``BACKOFF_SECONDS`` entry; anything + else propagates at once. Raises :class:`ReadFailed` when the budget is spent.""" + from .lifecycle import _is_transient_error # lifecycle imports this module; keep the cycle lazy + + delays = [0.0] * IMMEDIATE_RETRIES + list(BACKOFF_SECONDS) + for attempt in range(len(delays) + 1): + try: + return fn(attempt) + except Exception as exc: # noqa: BLE001 — classified right below + if not _is_transient_error(exc): + raise + if attempt == len(delays): + raise ReadFailed(attempt + 1, exc) from exc + sleep(delays[attempt]) + raise AssertionError("unreachable") + + +def rotate(urls: Sequence[str], attempt: int) -> str: + """The endpoint for *attempt*: walks a list of public RPC URLs so retries land on a different host.""" + return urls[attempt % len(urls)] + + +__all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "ReadFailed", "read_with_retry", "rotate"] diff --git a/bridge-sdk/tests/test_pause_retry.py b/bridge-sdk/tests/test_pause_retry.py new file mode 100644 index 00000000..6001b67e --- /dev/null +++ b/bridge-sdk/tests/test_pause_retry.py @@ -0,0 +1,57 @@ +"""The pause reader's retry loop: 2 immediate retries, then exponential backoff, then fail closed.""" +import pytest +import requests + +from aleo_bridge.errors import BridgeError, ConfigurationError +from aleo_bridge.pause import BACKOFF_SECONDS, IMMEDIATE_RETRIES, ReadFailed, read_with_retry, rotate + + +def _flaky(fail_times, exc=requests.ConnectionError("boom")): + calls = [] + + def fn(attempt): + calls.append(attempt) + if len(calls) <= fail_times: + raise exc + return "ok" + return fn, calls + + +def test_two_quick_retries_then_backoff_then_exhausted(): + sleeps = [] + fn, calls = _flaky(99) + with pytest.raises(ReadFailed) as info: + read_with_retry(fn, sleep=sleeps.append) + assert len(calls) == 1 + IMMEDIATE_RETRIES + len(BACKOFF_SECONDS) == 7 + assert sleeps == [0, 0, 0.5, 1.0, 2.0, 4.0] + assert info.value.attempts == 7 and isinstance(info.value.last, requests.ConnectionError) + + +def test_success_on_fifth_attempt_returns_value(): + sleeps = [] + fn, calls = _flaky(4) + assert read_with_retry(fn, sleep=sleeps.append) == "ok" + assert len(calls) == 5 and sleeps == [0, 0, 0.5, 1.0] + + +def test_non_transient_error_propagates_immediately_with_no_sleep(): + sleeps = [] + fn, calls = _flaky(99, ConfigurationError("bad mapping")) + with pytest.raises(ConfigurationError): + read_with_retry(fn, sleep=sleeps.append) + assert len(calls) == 1 and sleeps == [] + + +def test_http_429_and_5xx_bridge_errors_are_retried(): + for text in ("HTTP status 429", "HTTP status 502"): + fn, calls = _flaky(1, BridgeError(f"rpc {text}")) + assert read_with_retry(fn, sleep=lambda s: None) == "ok" and len(calls) == 2 + + +def test_attempt_number_is_passed_to_fn(): + fn, calls = _flaky(3) + assert read_with_retry(fn, sleep=lambda s: None) == "ok" and calls == [0, 1, 2, 3] + + +def test_rotate(): + assert [rotate(("a", "b"), k) for k in range(5)] == ["a", "b", "a", "b", "a"] From fb3b0cae20725e2f0b9520cd10284f45d0db7b5b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:00:39 -0400 Subject: [PATCH 03/13] feat(bridge): read Aleo pause mappings with a declared-mapping guard --- bridge-sdk/python/aleo_bridge/pause.py | 54 +++++++++++++++++++++++- bridge-sdk/tests/test_pause_aleo.py | 57 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/tests/test_pause_aleo.py diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index 14fc781b..79afddc4 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -17,7 +17,10 @@ from __future__ import annotations import time -from typing import Callable, Sequence, TypeVar +from typing import Any, Callable, Sequence, TypeVar + +from .errors import ConfigurationError +from .types import PauseSwitch T = TypeVar("T") @@ -57,4 +60,51 @@ def rotate(urls: Sequence[str], attempt: int) -> str: return urls[attempt % len(urls)] -__all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "ReadFailed", "read_with_retry", "rotate"] +# ── Aleo ───────────────────────────────────────────────────────────────────── + +def _aleo_mapping_value(bridge: Any, program: str, mapping: str, key: str, sleep: Callable[[float], None]) -> str | None: + """The mapping value, after confirming the program declares *mapping*. + + The node answers ``null`` for a missing key, for a mapping the program never declared and for a + program that does not exist, so a typo in a pinned mapping name would read as "open" forever. + Checking the declared list first turns that into a ``ConfigurationError``. + """ + from aleo.facade.errors import ProgramNotFound + + def declared(_attempt: int) -> list[str]: + try: + return list(bridge.program(program).mappings()) + except ProgramNotFound: + return [] + if mapping not in read_with_retry(declared, sleep=sleep): + raise ConfigurationError( + f"{program} does not declare mapping {mapping!r}; the registry pins a program this node does not serve") + return read_with_retry(lambda _attempt: bridge.mapping_value(program, mapping, key), sleep=sleep) + + +def read_aleo_flag(bridge: Any, *, chain_id: str, side: str, program: str, mapping: str, key: str, + sleep: Callable[[float], None] = time.sleep) -> PauseSwitch: + """A boolean pause mapping (``pause[true]``, ``paused[true]``, the ARC-20 ``pause__[false]``): ``true`` is paused.""" + switch = f"{mapping}[{key}]" + try: + value = _aleo_mapping_value(bridge, program, mapping, key, sleep) + except ReadFailed as exc: + return PauseSwitch(chain_id, side, program, switch, "unknown", str(exc)) + if value not in (None, "true", "false"): + raise ConfigurationError(f"{program}.{switch} expected a boolean literal, got {value!r}") + return PauseSwitch(chain_id, side, program, switch, "paused" if value == "true" else "open", value) + + +def read_aleo_remote_router(bridge: Any, *, chain_id: str, side: str, program: str, domain: int, + sleep: Callable[[float], None] = time.sleep) -> PauseSwitch: + """The warp program's enrolled router for *domain*; a missing entry means the operator un-enrolled the route.""" + switch = f"remote_routers[{domain}u32]" + try: + value = _aleo_mapping_value(bridge, program, "remote_routers", f"{domain}u32", sleep) + except ReadFailed as exc: + return PauseSwitch(chain_id, side, program, switch, "unknown", str(exc)) + return PauseSwitch(chain_id, side, program, switch, "open" if value is not None else "paused", value) + + +__all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "ReadFailed", "read_aleo_flag", "read_aleo_remote_router", + "read_with_retry", "rotate"] diff --git a/bridge-sdk/tests/test_pause_aleo.py b/bridge-sdk/tests/test_pause_aleo.py new file mode 100644 index 00000000..cdf24cd4 --- /dev/null +++ b/bridge-sdk/tests/test_pause_aleo.py @@ -0,0 +1,57 @@ +"""Aleo-side pause switches: boolean mappings and the enrolled remote-router table, with the declared-mapping guard.""" +import pytest +import requests + +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.pause import read_aleo_flag, read_aleo_remote_router +from tests.fakes.fake_web3 import make_bridge + +WARP = "hyp_warp_token_eth_v2.aleo" + + +def _bridge(mappings, **kw): + return make_bridge(ethereum=None, mappings=mappings, **kw) + + +def _flag(b, **kw): + return read_aleo_flag(b, chain_id="aleo", side="source", program=WARP, mapping="pause", key="true", **kw) + + +def test_paused_open_and_missing_key(): + assert _flag(_bridge({WARP: {"pause": {"true": "true"}}})).state == "paused" + s = _flag(_bridge({WARP: {"pause": {"true": "false"}}})) + assert (s.state, s.switch, s.locator, s.detail, s.chain_id, s.side) == ("open", "pause[true]", WARP, "false", "aleo", "source") + assert _flag(_bridge({WARP: {"pause": {}}})).state == "open" # declared, key never set + + +def test_undeclared_mapping_is_a_configuration_error(): + with pytest.raises(ConfigurationError, match="does not declare mapping 'pause'"): + _flag(_bridge({WARP: {"remote_routers": {}}})) + + +def test_missing_program_is_a_configuration_error(): + with pytest.raises(ConfigurationError, match="does not declare mapping"): + _flag(_bridge({}, missing_programs={WARP})) + + +def test_malformed_value_is_a_configuration_error(): + with pytest.raises(ConfigurationError, match="expected a boolean"): + _flag(_bridge({WARP: {"pause": {"true": "7u8"}}})) + + +def test_remote_router_present_is_open_and_missing_is_paused(): + b = _bridge({WARP: {"remote_routers": {"1u32": "{ domain: 1u32, recipient: [0u8], gas: 1u128 }"}}}) + assert read_aleo_remote_router(b, chain_id="aleo", side="source", program=WARP, domain=1).state == "open" + s = read_aleo_remote_router(b, chain_id="aleo", side="source", program=WARP, domain=1399811149) + assert s.state == "paused" and s.switch == "remote_routers[1399811149u32]" + + +def test_transient_read_errors_become_unknown_after_retries(monkeypatch): + b = _bridge({WARP: {"pause": {"true": "false"}}}) + + def boom(*a, **k): + raise requests.ConnectionError("rpc down") + monkeypatch.setattr(b, "mapping_value", boom) + sleeps = [] + s = _flag(b, sleep=sleeps.append) + assert s.state == "unknown" and "after 7 attempts" in s.detail and sleeps == [0, 0, 0.5, 1.0, 2.0, 4.0] From 417cb7b598da72971b4f527b6ff0f0f1e81a8378 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:02:36 -0400 Subject: [PATCH 04/13] feat(bridge): read EVM pause switches including the Mailbox hook and ISM trees --- bridge-sdk/python/aleo_bridge/_evm_abi.py | 24 ++++- bridge-sdk/python/aleo_bridge/pause.py | 97 ++++++++++++++++++++- bridge-sdk/tests/fakes/fake_web3.py | 16 +++- bridge-sdk/tests/test_pause_evm.py | 101 ++++++++++++++++++++++ 4 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 bridge-sdk/tests/test_pause_evm.py diff --git a/bridge-sdk/python/aleo_bridge/_evm_abi.py b/bridge-sdk/python/aleo_bridge/_evm_abi.py index 5fdb7fc5..653d447d 100644 --- a/bridge-sdk/python/aleo_bridge/_evm_abi.py +++ b/bridge-sdk/python/aleo_bridge/_evm_abi.py @@ -57,4 +57,26 @@ "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"name": "", "type": "bool"}]}, ] -__all__ = ["ERC20_ABI", "EVM_CHAIN_BY_ENVIRONMENT", "MAILBOX_ABI", "WARP_ROUTE_ABI", "XRESERVE_ABI", "ZERO_ADDRESS"] +def _view(name: str, inputs: list[tuple[str, str]], outputs: list[tuple[str, str]]) -> dict: + return {"type": "function", "name": name, "stateMutability": "view", + "inputs": [{"name": n, "type": t} for n, t in inputs], + "outputs": [{"name": n, "type": t} for n, t in outputs]} + + +# Pause switches and the Hyperlane Mailbox hook / ISM tree getters (OpenZeppelin Pausable, Hyperlane +# IPostDispatchHook / IInterchainSecurityModule, DomainRoutingHook, StaticAggregationHook/Ism, DomainRoutingIsm). +PAUSE_ABI = [ + _view("paused", [], [("", "bool")]), + _view("hookType", [], [("", "uint8")]), + _view("moduleType", [], [("", "uint8")]), + _view("defaultHook", [], [("", "address")]), + _view("requiredHook", [], [("", "address")]), + _view("defaultIsm", [], [("", "address")]), + _view("hooks", [("destination", "uint32")], [("", "address")]), + _view("hooks", [("message", "bytes")], [("", "address[]")]), + _view("modulesAndThreshold", [("message", "bytes")], [("modules", "address[]"), ("threshold", "uint8")]), + _view("module", [("origin", "uint32")], [("", "address")]), +] + +__all__ = ["ERC20_ABI", "EVM_CHAIN_BY_ENVIRONMENT", "MAILBOX_ABI", "PAUSE_ABI", "WARP_ROUTE_ABI", "XRESERVE_ABI", + "ZERO_ADDRESS"] diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index 79afddc4..c2a93142 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -106,5 +106,100 @@ def read_aleo_remote_router(bridge: Any, *, chain_id: str, side: str, program: s return PauseSwitch(chain_id, side, program, switch, "open" if value is not None else "paused", value) +# ── EVM ────────────────────────────────────────────────────────────────────── + +HOOK_ROUTING, HOOK_AGGREGATION, HOOK_FALLBACK_ROUTING, HOOK_PAUSABLE = 1, 2, 5, 7 # Hyperlane IPostDispatchHook.Types +ISM_ROUTING, ISM_AGGREGATION, ISM_NULL = 1, 2, 6 # IInterchainSecurityModule.Types +TREE_DEPTH_LIMIT = 4 + + +class _TreeTooDeep(Exception): + pass + + +def _evm_call(w3: Any, address: str, fn: str, *args: Any, sleep: Callable[[float], None]) -> Any: + """One view call through the retry loop. A revert is a ``ConfigurationError`` (the registry names a + contract that does not answer this getter), never retried.""" + from .eth import _web3 + from ._evm_abi import PAUSE_ABI + web3 = _web3() + contract = w3.eth.contract(address=web3.Web3.to_checksum_address(address), abi=PAUSE_ABI) + try: + return read_with_retry(lambda _attempt: getattr(contract.functions, fn)(*args).call(), sleep=sleep) + except web3.exceptions.ContractLogicError as exc: + raise ConfigurationError(f"{fn}() on {address} reverted: {exc}") from exc + + +def read_evm_paused(w3: Any, *, chain_id: str, side: str, address: str, + sleep: Callable[[float], None] = time.sleep) -> PauseSwitch: + """OpenZeppelin ``paused()`` on an xReserve or CCTP MessageTransmitter contract.""" + try: + paused = bool(_evm_call(w3, address, "paused", sleep=sleep)) + except ReadFailed as exc: + return PauseSwitch(chain_id, side, address, "paused()", "unknown", str(exc)) + return PauseSwitch(chain_id, side, address, "paused()", "paused" if paused else "open", str(paused).lower()) + + +def _walk(w3: Any, address: str, *, far_domain: int, kind: str, depth: int, sleep: Callable[[float], None], + out: list[tuple[str, bool]]) -> None: + """Depth-first over a Hyperlane hook (``kind="hook"``) or ISM tree, collecting (address, paused) for every + pausable member. Routing nodes resolve *far_domain*; aggregation nodes expand their members.""" + if depth > TREE_DEPTH_LIMIT: + raise _TreeTooDeep(address) + if kind == "hook": + node_type = _evm_call(w3, address, "hookType", sleep=sleep) + if node_type in (HOOK_ROUTING, HOOK_FALLBACK_ROUTING): + _walk(w3, _evm_call(w3, address, "hooks", far_domain, sleep=sleep), far_domain=far_domain, kind=kind, + depth=depth + 1, sleep=sleep, out=out) + elif node_type == HOOK_AGGREGATION: + for member in _evm_call(w3, address, "hooks", b"", sleep=sleep): + _walk(w3, member, far_domain=far_domain, kind=kind, depth=depth + 1, sleep=sleep, out=out) + elif node_type == HOOK_PAUSABLE: + out.append((address, bool(_evm_call(w3, address, "paused", sleep=sleep)))) + return + node_type = _evm_call(w3, address, "moduleType", sleep=sleep) + if node_type == ISM_ROUTING: + _walk(w3, _evm_call(w3, address, "module", far_domain, sleep=sleep), far_domain=far_domain, kind=kind, + depth=depth + 1, sleep=sleep, out=out) + elif node_type == ISM_AGGREGATION: + for member in _evm_call(w3, address, "modulesAndThreshold", b"", sleep=sleep)[0]: + _walk(w3, member, far_domain=far_domain, kind=kind, depth=depth + 1, sleep=sleep, out=out) + elif node_type == ISM_NULL: + try: # a PausableIsm reports NULL; a plain NullIsm/TrustedRelayerIsm has no paused() + out.append((address, bool(_evm_call(w3, address, "paused", sleep=sleep)))) + except ConfigurationError: + pass + + +def _tree_switches(w3: Any, *, chain_id: str, side: str, mailbox: str, far_domain: int, kind: str, + roots: tuple[str, ...], switch: str, sleep: Callable[[float], None]) -> list[PauseSwitch]: + found: list[tuple[str, bool]] = [] + try: + for getter in roots: + _walk(w3, _evm_call(w3, mailbox, getter, sleep=sleep), far_domain=far_domain, kind=kind, depth=1, + sleep=sleep, out=found) + except ReadFailed as exc: + return [PauseSwitch(chain_id, side, mailbox, switch, "unknown", str(exc))] + except _TreeTooDeep as exc: + return [PauseSwitch(chain_id, side, mailbox, switch, "unknown", + f"{kind} tree deeper than depth {TREE_DEPTH_LIMIT} at {exc}")] + return [PauseSwitch(chain_id, side, address, switch, "paused" if paused else "open", str(paused).lower()) + for address, paused in found] + + +def read_mailbox_hook_pauses(w3: Any, *, chain_id: str, side: str, mailbox: str, far_domain: int, + sleep: Callable[[float], None] = time.sleep) -> list[PauseSwitch]: + """Every ``PausableHook`` under the Mailbox's default and required hooks for *far_domain* (outbound dispatch).""" + return _tree_switches(w3, chain_id=chain_id, side=side, mailbox=mailbox, far_domain=far_domain, kind="hook", + roots=("defaultHook", "requiredHook"), switch="PausableHook.paused()", sleep=sleep) + + +def read_mailbox_ism_pauses(w3: Any, *, chain_id: str, side: str, mailbox: str, far_domain: int, + sleep: Callable[[float], None] = time.sleep) -> list[PauseSwitch]: + """Every ``PausableIsm`` under the Mailbox's default ISM for messages from *far_domain* (inbound delivery).""" + return _tree_switches(w3, chain_id=chain_id, side=side, mailbox=mailbox, far_domain=far_domain, kind="ism", + roots=("defaultIsm",), switch="PausableIsm.paused()", sleep=sleep) + + __all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "ReadFailed", "read_aleo_flag", "read_aleo_remote_router", - "read_with_retry", "rotate"] + "read_evm_paused", "read_mailbox_hook_pauses", "read_mailbox_ism_pauses", "read_with_retry", "rotate"] diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index 590c1b1c..ad0bd72e 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -113,9 +113,14 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N token_balances: dict[tuple[str, str], int] | None = None, allowances: dict[tuple[str, str, str], int] | None = None, quotes: dict[str, list[tuple[str, int]]] | None = None, - delivered: set[str] | None = None, legacy: bool = False) -> None: + delivered: set[str] | None = None, legacy: bool = False, + calls: dict[tuple[str, str], Any] | None = None) -> None: super().__init__() self.chain_id = chain_id + # Scripted eth_call answers keyed by (address, 0x-selector): ABI-encoded return bytes, "revert" + # (JSON-RPC error 3 "execution reverted"), or an Exception instance raised from make_request. + self.calls = {(to_checksum_address(a), s.lower()): v for (a, s), v in (calls or {}).items()} + self.call_log: list[tuple[str, str]] = [] # (address, selector) of every scripted lookup self.legacy = legacy # True: eth_getBlockByNumber omits baseFeePerGas self.eth_balances = {to_checksum_address(k): v for k, v in (eth_balances or {}).items()} self.token_balances = {(to_checksum_address(t), to_checksum_address(o)): v @@ -227,6 +232,15 @@ def make_request(self, method: str, params: Any) -> dict: if method == "eth_getBalance": return self._ok(_hex(self.eth_balances.get(to_checksum_address(params[0]), 0))) if method == "eth_call": + key = (to_checksum_address(params[0]["to"]), params[0]["data"][:10].lower()) + if key in self.calls: + self.call_log.append(key) + scripted = self.calls[key] + if isinstance(scripted, Exception): + raise scripted + if scripted == "revert": + return {"jsonrpc": "2.0", "id": 1, "error": {"code": 3, "message": "execution reverted"}} + return self._ok("0x" + bytes(scripted).hex()) return self._ok(self._call(params[0])) if method == "eth_sendRawTransaction": raw = bytes.fromhex(params[0][2:]) diff --git a/bridge-sdk/tests/test_pause_evm.py b/bridge-sdk/tests/test_pause_evm.py new file mode 100644 index 00000000..770472d1 --- /dev/null +++ b/bridge-sdk/tests/test_pause_evm.py @@ -0,0 +1,101 @@ +"""EVM-side pause switches: ``paused()`` and the Hyperlane Mailbox hook / ISM trees.""" +import pytest +import requests +from eth_abi import encode +from web3 import Web3 + +from aleo_bridge._keccak import keccak256 +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.pause import read_evm_paused, read_mailbox_hook_pauses, read_mailbox_ism_pauses +from tests.fakes.fake_web3 import fake_web3 + +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +XRESERVE = "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" +FALLBACK, AGG, PAUSABLE, MERKLE, IGP, FEE = (f"0x{n:040x}" for n in range(0xA1, 0xA7)) +ISM_AGG, ISM_NULL_PLAIN, ISM_ROUTING, ISM_AGG2, ISM_PAUSABLE = (f"0x{n:040x}" for n in range(0xB1, 0xB6)) +ALEO_DOMAIN = 1634493807 + + +def sel(sig: str) -> str: + return "0x" + keccak256(sig.encode())[:4].hex() + + +def addr(a: str) -> bytes: + return encode(["address"], [a]) + + +def addrs(*a: str) -> bytes: + return encode(["address[]"], [list(a)]) + + +def u8(n: int) -> bytes: + return encode(["uint8"], [n]) + + +def boolean(b: bool) -> bytes: + return encode(["bool"], [b]) + + +def test_paused_true_false_and_unknown(): + w3 = fake_web3(calls={(XRESERVE, sel("paused()")): boolean(True)}) + s = read_evm_paused(w3, chain_id="ethereum", side="destination", address=XRESERVE) + assert (s.state, s.switch, s.locator, s.chain_id, s.side) == ("paused", "paused()", XRESERVE, "ethereum", "destination") + w3 = fake_web3(calls={(XRESERVE, sel("paused()")): boolean(False)}) + assert read_evm_paused(w3, chain_id="ethereum", side="source", address=XRESERVE).state == "open" + w3 = fake_web3(calls={(XRESERVE, sel("paused()")): requests.ConnectionError("rpc down")}) + sleeps = [] + s = read_evm_paused(w3, chain_id="ethereum", side="source", address=XRESERVE, sleep=sleeps.append) + assert s.state == "unknown" and "after 7 attempts" in s.detail and sleeps == [0, 0, 0.5, 1.0, 2.0, 4.0] + + +def _hook_tree(pausable: bool) -> dict: + return { + (MAILBOX, sel("defaultHook()")): addr(FALLBACK), (MAILBOX, sel("requiredHook()")): addr(FEE), + (FALLBACK, sel("hookType()")): u8(5), (FALLBACK, sel("hooks(uint32)")): addr(AGG), + (AGG, sel("hookType()")): u8(2), (AGG, sel("hooks(bytes)")): addrs(PAUSABLE, MERKLE, IGP), + (PAUSABLE, sel("hookType()")): u8(7), (PAUSABLE, sel("paused()")): boolean(pausable), + (MERKLE, sel("hookType()")): u8(3), (IGP, sel("hookType()")): u8(4), (FEE, sel("hookType()")): u8(8), + } + + +def test_hook_tree_finds_the_pausable_member(): + w3 = fake_web3(calls=_hook_tree(False)) + switches = read_mailbox_hook_pauses(w3, chain_id="ethereum", side="source", mailbox=MAILBOX, far_domain=ALEO_DOMAIN) + assert [(s.state, s.switch, s.locator) for s in switches] == [("open", "PausableHook.paused()", Web3.to_checksum_address(PAUSABLE))] + + +def test_hook_tree_pausable_true_is_paused(): + w3 = fake_web3(calls=_hook_tree(True)) + switches = read_mailbox_hook_pauses(w3, chain_id="ethereum", side="source", mailbox=MAILBOX, far_domain=ALEO_DOMAIN) + assert [s.state for s in switches] == ["paused"] + + +def test_ism_tree_descends_routing_then_aggregation_and_probes_null(): + calls = { + (MAILBOX, sel("defaultIsm()")): addr(ISM_AGG), + (ISM_AGG, sel("moduleType()")): u8(2), + (ISM_AGG, sel("modulesAndThreshold(bytes)")): encode(["address[]", "uint8"], [[ISM_NULL_PLAIN, ISM_ROUTING], 2]), + (ISM_NULL_PLAIN, sel("moduleType()")): u8(6), (ISM_NULL_PLAIN, sel("paused()")): "revert", + (ISM_ROUTING, sel("moduleType()")): u8(1), (ISM_ROUTING, sel("module(uint32)")): addr(ISM_AGG2), + (ISM_AGG2, sel("moduleType()")): u8(2), + (ISM_AGG2, sel("modulesAndThreshold(bytes)")): encode(["address[]", "uint8"], [[ISM_PAUSABLE], 1]), + (ISM_PAUSABLE, sel("moduleType()")): u8(6), (ISM_PAUSABLE, sel("paused()")): boolean(True), + } + w3 = fake_web3(calls=calls) + switches = read_mailbox_ism_pauses(w3, chain_id="ethereum", side="destination", mailbox=MAILBOX, far_domain=ALEO_DOMAIN) + assert [(s.state, s.switch, s.locator) for s in switches] == [("paused", "PausableIsm.paused()", ISM_PAUSABLE)] + + +def test_depth_cap_stops_a_cyclic_aggregation(): + calls = {(MAILBOX, sel("defaultHook()")): addr(AGG), (MAILBOX, sel("requiredHook()")): addr(FEE), + (AGG, sel("hookType()")): u8(2), (AGG, sel("hooks(bytes)")): addrs(AGG), (FEE, sel("hookType()")): u8(8)} + w3 = fake_web3(calls=calls) + switches = read_mailbox_hook_pauses(w3, chain_id="ethereum", side="source", mailbox=MAILBOX, far_domain=ALEO_DOMAIN) + assert [s.state for s in switches] == ["unknown"] and "depth" in switches[0].detail + + +def test_revert_on_hook_type_is_a_configuration_error(): + calls = {(MAILBOX, sel("defaultHook()")): addr(FALLBACK), (FALLBACK, sel("hookType()")): "revert"} + w3 = fake_web3(calls=calls) + with pytest.raises(ConfigurationError, match="reverted"): + read_mailbox_hook_pauses(w3, chain_id="ethereum", side="source", mailbox=MAILBOX, far_domain=ALEO_DOMAIN) From 0ff172f2531b729c5983ba6b516d13b8c4bfaa29 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:05:01 -0400 Subject: [PATCH 05/13] feat(bridge): ETHEREUM_RPC_URL alone yields a read-only connection --- bridge-sdk/python/aleo_bridge/agent.py | 14 ++++++++++---- bridge-sdk/python/aleo_bridge/client.py | 2 +- bridge-sdk/python/aleo_bridge/eth.py | 14 +++++++------- bridge-sdk/tests/test_agent.py | 9 +++++++++ bridge-sdk/tests/test_bridge_eth_wiring.py | 6 +++--- bridge-sdk/tests/test_client.py | 2 +- bridge-sdk/tests/test_eth_connection.py | 10 +++++----- 7 files changed, 36 insertions(+), 21 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index 1746f031..99d39f2f 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -174,18 +174,24 @@ def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: return payload -def _connection_gap(bridge: Any, source_chain: Any) -> dict[str, Any] | None: +def _connection_gap(bridge: Any, source_chain: Any, *, signing: bool = False) -> dict[str, Any] | None: """Probe the connection a transfer out of *source_chain* would sign with (spec §10: a missing - connection is a configuration answer the model can act on, not an exception).""" + connection is a configuration answer the model can act on, not an exception). ``signing`` also + reports a URL-only (read-only) Ethereum connection, which can quote but never send.""" try: chain = bridge.registry.chain(source_chain) family = chain.family except (BridgeError, TypeError): return None # let the real lookup produce the real error - if family == "evm" and getattr(bridge, "ethereum", None) is None: + ethereum = getattr(bridge, "ethereum", None) + if family == "evm" and ethereum is None: return _error_payload(ConfigurationError( f"No Ethereum connection is configured, and transfers out of {chain.id} are signed " "there."), how_to_fix=EVM_HOW_TO_FIX) + if family == "evm" and signing and not getattr(ethereum, "can_sign", True): + return _error_payload(ConfigurationError( + f"The Ethereum connection is read-only (no EVM_PRIVATE_KEY), and transfers out of {chain.id} " + "are signed there."), how_to_fix=EVM_HOW_TO_FIX) if family == "solana" and getattr(bridge, "solana", None) is None: return _error_payload(ConfigurationError( f"No Solana connection is configured, and transfers out of {chain.id} are signed " @@ -355,7 +361,7 @@ def _h_pending(b, a): # ── writes (confirm-gated) ──────────────────────────────────────────────────── def _h_execute(b, a): - gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source_chain")) + gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source_chain"), signing=True) if gap is not None: return gap quote = lifecycle.quote(b, **_quote_kwargs(a)) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 68880c6d..43f3d22f 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -70,7 +70,7 @@ def build_aleo(endpoint: str, network: str, private_key: str, *, api_key: str | def ethereum_from_env() -> Any: - """``Ethereum(ETHEREUM_RPC_URL, private_key=EVM_PRIVATE_KEY)`` or None; both variables or neither.""" + """``Ethereum(ETHEREUM_RPC_URL, private_key=EVM_PRIVATE_KEY)``; the URL alone is read-only; neither → None.""" return Ethereum.from_env() diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 5d95a162..1a8ea9d8 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -159,12 +159,12 @@ def __init__(self, rpc_url: str | None = None, *, w3: Any = None, signer: Any = @classmethod def from_env(cls, env: Mapping[str, str] | None = None) -> "Ethereum | None": - """``EVM_PRIVATE_KEY`` + ``ETHEREUM_RPC_URL`` (both or neither) → signing connection; neither → None. + """``EVM_PRIVATE_KEY`` + ``ETHEREUM_RPC_URL`` → signing connection; the URL alone → a read-only + connection (like Solana and the other EVM chains); neither → None; a key alone is an error. Aliases (the user's live shell / veil config export these names instead): ``BRIDGE_EVM_PRIVATE_KEY`` for the key, ``BRIDGE_LIVE_ETHEREUM_RPC_URL`` for the RPC url. - The primary variable wins when both a primary and its alias are set; the both-or-neither - rule applies to whichever pair resolves (primary, falling back to alias, per variable). + The primary variable wins when both a primary and its alias are set. ``BRIDGE_MIN_PRIORITY_FEE_WEI`` (digits only) overrides the EIP-1559 tip floor, for a chain or a moment where 0.1 gwei is not enough to be mined. @@ -172,17 +172,17 @@ def from_env(cls, env: Mapping[str, str] | None = None) -> "Ethereum | None": env = os.environ if env is None else env key = env.get("EVM_PRIVATE_KEY") or env.get("BRIDGE_EVM_PRIVATE_KEY") url = env.get("ETHEREUM_RPC_URL") or env.get("BRIDGE_LIVE_ETHEREUM_RPC_URL") - if bool(key) != bool(url): + if key and not url: raise ConfigurationError( - "Set both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL or neither " + "EVM_PRIVATE_KEY is set but ETHEREUM_RPC_URL is not: a key needs an RPC to sign against " "(aliases: BRIDGE_EVM_PRIVATE_KEY, BRIDGE_LIVE_ETHEREUM_RPC_URL)") - if not key: + if not url: return None floor = env.get("BRIDGE_MIN_PRIORITY_FEE_WEI") or None # exported-but-empty reads as unset if floor is not None and not str(floor).isdigit(): raise ConfigurationError( f"BRIDGE_MIN_PRIORITY_FEE_WEI must be a whole number of wei; got {floor!r}") - return cls(url, private_key=key, + return cls(url, private_key=key or None, min_priority_fee_wei=MIN_PRIORITY_FEE_WEI if floor is None else int(floor)) @property diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py index 1cdfdadd..c025b1d6 100644 --- a/bridge-sdk/tests/test_agent.py +++ b/bridge-sdk/tests/test_agent.py @@ -415,6 +415,15 @@ def test_unconfigured_chain_returns_a_structured_error(): assert "SOLANA_PRIVATE_KEY" in out["how_to_fix"] +def test_read_only_evm_connection_is_reported_as_a_signing_gap(): + b = FakeBridge() + b.ethereum.can_sign = False # ETHEREUM_RPC_URL without a key + assert "error" not in dispatch_tool(b, "bridge_quote", dict(QUOTE_ARGS)) # reads still work + out = dispatch_tool(b, "bridge_execute", {**QUOTE_ARGS, "confirm": True}) + assert out["error_type"] == "ConfigurationError" and "read-only" in out["error"] + assert "EVM_PRIVATE_KEY" in out["how_to_fix"] and b.events == [] + + def test_bridge_error_from_a_read_is_structured_too(): out = dispatch_tool(FakeBridge(), "bridge_quote", {**QUOTE_ARGS, "amount": "0"}) assert out["error"] and out["error_type"] == "InvalidAmountError" diff --git a/bridge-sdk/tests/test_bridge_eth_wiring.py b/bridge-sdk/tests/test_bridge_eth_wiring.py index 00647e05..7ed500c1 100644 --- a/bridge-sdk/tests/test_bridge_eth_wiring.py +++ b/bridge-sdk/tests/test_bridge_eth_wiring.py @@ -53,12 +53,12 @@ def test_from_env_requires_both_evm_variables(monkeypatch): monkeypatch.delenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", raising=False) monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) monkeypatch.delenv("ETHEREUM_RPC_URL", raising=False) - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + with pytest.raises(ConfigurationError, match="EVM_PRIVATE_KEY is set but ETHEREUM_RPC_URL"): Bridge.from_env() monkeypatch.delenv("EVM_PRIVATE_KEY") monkeypatch.setenv("ETHEREUM_RPC_URL", "http://127.0.0.1:1") - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): - Bridge.from_env() + read_only = Bridge.from_env() # a URL alone is a read-only connection + assert read_only.ethereum is not None and read_only.ethereum.can_sign is False monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) bridge = Bridge.from_env() assert bridge.ethereum.address == ACCT.address and isinstance(bridge.ethereum.w3.provider, HTTPProvider) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 2da943f0..e4e762f3 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -176,7 +176,7 @@ def test_from_env_side_chain_variables(monkeypatch, tmp_path): "BRIDGE_CHECKPOINT_DIR"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("EVM_PRIVATE_KEY", "0x" + "11" * 32) - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + with pytest.raises(ConfigurationError, match="EVM_PRIVATE_KEY is set but ETHEREUM_RPC_URL"): Bridge.from_env() monkeypatch.setenv("ETHEREUM_RPC_URL", "https://eth.example") bridge = Bridge.from_env() # plan 2: real Ethereum connection now constructed diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py index d8336fed..02eac8c7 100644 --- a/bridge-sdk/tests/test_eth_connection.py +++ b/bridge-sdk/tests/test_eth_connection.py @@ -275,14 +275,14 @@ def test_get_receipt_missing_hash_is_none(): assert conn.get_receipt("0x" + "99" * 32) is None -def test_from_env(): +def test_from_env_url_only_is_read_only_and_key_only_still_raises(): from aleo_bridge.eth import Ethereum assert Ethereum.from_env({}) is None - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + with pytest.raises(ConfigurationError, match="EVM_PRIVATE_KEY is set but ETHEREUM_RPC_URL"): Ethereum.from_env({"EVM_PRIVATE_KEY": KEY}) - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): - Ethereum.from_env({"ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + ro = Ethereum.from_env({"ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + assert ro is not None and ro.can_sign is False and ro.address is None conn = Ethereum.from_env({"EVM_PRIVATE_KEY": KEY, "ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" @@ -294,7 +294,7 @@ def test_from_env_aliases(monkeypatch): for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("BRIDGE_EVM_PRIVATE_KEY", KEY) - with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + with pytest.raises(ConfigurationError, match="EVM_PRIVATE_KEY is set but ETHEREUM_RPC_URL"): Ethereum.from_env() monkeypatch.setenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", "http://127.0.0.1:1") conn = Ethereum.from_env() From b487255cf7589831e0e9bb33515186ff5c6ccabe Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:05:44 -0400 Subject: [PATCH 06/13] feat(bridge): registry-pinned public read RPC endpoints per EVM chain --- .../python/aleo_bridge/_registry_data.py | 12 ++++++++++ bridge-sdk/python/aleo_bridge/registry.py | 16 ++++++++++++-- bridge-sdk/tests/test_registry.py | 22 +++++++++++++++++++ .../tests/test_registry_compatibility.py | 8 +++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py index 53e03b12..8238236a 100644 --- a/bridge-sdk/python/aleo_bridge/_registry_data.py +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -30,6 +30,18 @@ "protocolDomains": {"hyperlane": 11155111, "xreserve": 0}}, ] +# Public JSON-RPC endpoints used ONLY to read pause switches on a chain the caller configured no +# 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 = { + "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"], + "arbitrum": ["https://arbitrum-one-rpc.publicnode.com", "https://1rpc.io/arb"], + "hyperevm": ["https://rpc.hyperliquid.xyz/evm"], + "sepolia": ["https://ethereum-sepolia-rpc.publicnode.com", "https://1rpc.io/sepolia"], +} + ASSETS = [ {"id": "aleo/aleo", "key": "aleo", "chainId": "aleo", "symbol": "ALEO", "name": "Aleo", "decimals": 6, "kind": "native", "locator": {"kind": "aleo-program", "value": "credits.aleo"}, "addressValidationRegex": ALEO_ADDRESS}, diff --git a/bridge-sdk/python/aleo_bridge/registry.py b/bridge-sdk/python/aleo_bridge/registry.py index 01d7389e..0a84151b 100644 --- a/bridge-sdk/python/aleo_bridge/registry.py +++ b/bridge-sdk/python/aleo_bridge/registry.py @@ -119,7 +119,8 @@ def _parse_asset_ref(ref: Any) -> tuple[str, str]: class Registry: """Immutable snapshot with case-insensitive lookups. Build with :func:`build_registry` or directly.""" - def __init__(self, version: str, chains: Iterable[Chain], assets: Iterable[Asset], routes: Iterable[Route]) -> None: + def __init__(self, version: str, chains: Iterable[Chain], assets: Iterable[Asset], routes: Iterable[Route], + public_read_rpc_urls: Mapping[str, Iterable[str]] | None = None) -> None: self.version = version self._chains = tuple(chains) self._assets = tuple(assets) @@ -127,6 +128,11 @@ def __init__(self, version: str, chains: Iterable[Chain], assets: Iterable[Asset self._chain_by_id = {c.id: c for c in self._chains} self._asset_by_id = {a.id: a for a in self._assets} self._route_by_id = {r.id: r for r in self._routes} + self._public_read_rpc_urls = {k.lower(): tuple(v) for k, v in (public_read_rpc_urls or {}).items()} + + def public_read_rpc_urls(self, chain_id: str) -> tuple[str, ...]: + """Public read-only JSON-RPC endpoints pinned for an EVM chain; used only for pause-switch reads.""" + return self._public_read_rpc_urls.get(str(chain_id).lower(), ()) def __repr__(self) -> str: return f"Registry(version={self.version!r}, chains={len(self._chains)}, assets={len(self._assets)}, routes={len(self._routes)})" @@ -245,6 +251,11 @@ def validate_registry(registry: Registry) -> Registry: if chain.family not in FAMILIES: raise ConfigurationError(f"Bridge chain {chain.id} has unsupported family {chain.family!r}") chain_ids.add(chain.id) + for chain_id, urls in registry._public_read_rpc_urls.items(): + if chain_id not in chain_ids: + raise ConfigurationError(f"Public read RPC URLs name unknown chain {chain_id!r}") + if not urls or not all(isinstance(u, str) and u.startswith("https://") for u in urls): + raise ConfigurationError(f"Public read RPC URLs for {chain_id} must be one or more https:// URLs") asset_ids: set[str] = set() asset_keys: set[str] = set() for asset in registry._assets: @@ -321,7 +332,8 @@ def build_registry(data: ModuleType = _registry_data) -> Registry: routes = [Route(r["id"], r["protocol"], r["environment"], r["sourceAssetId"], r["destinationAssetId"], r["availability"], r.get("deploymentId"), r.get("source"), dict(r.get("metadata", {}))) for r in data.ROUTES] - return validate_registry(Registry(data.REGISTRY_VERSION, chains, assets, routes)) + return validate_registry(Registry(data.REGISTRY_VERSION, chains, assets, routes, + getattr(data, "PUBLIC_READ_RPC_URLS", {}))) DEFAULT_REGISTRY: Registry = build_registry() diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py index 0e4ac7ed..b97f35fe 100644 --- a/bridge-sdk/tests/test_registry.py +++ b/bridge-sdk/tests/test_registry.py @@ -468,3 +468,25 @@ def test_solana_spl_collateral_routes_need_their_collateral_fields_to_be_active( inactive = Route(zec.id, zec.protocol, zec.environment, zec.source_asset_id, zec.destination_asset_id, "metadata-required", zec.deployment_id, zec.source, {}) validate_registry(Registry(base["version"], base["chains"], base["assets"], [inactive])) + + +def test_public_read_rpc_urls_cover_every_evm_chain(): + for chain in REG.chains(): + urls = REG.public_read_rpc_urls(chain.id) + if chain.family == "evm": + assert urls and all(u.startswith("https://") for u in urls), chain.id + else: + assert urls == () + assert REG.public_read_rpc_urls("ETHEREUM") == REG.public_read_rpc_urls("ethereum") + + +def test_registry_without_public_urls_still_constructs_and_validation_catches_bad_tables(): + routes = REG.routes(include_unavailable=True) + bare = Registry(REG.version, REG.chains(), REG.assets(), routes) + assert bare.public_read_rpc_urls("ethereum") == () and validate_registry(bare) is bare + with pytest.raises(ConfigurationError, match="https"): + validate_registry(Registry(REG.version, REG.chains(), REG.assets(), routes, + public_read_rpc_urls={"ethereum": ["http://insecure"]})) + with pytest.raises(ConfigurationError, match="unknown chain"): + validate_registry(Registry(REG.version, REG.chains(), REG.assets(), routes, + public_read_rpc_urls={"nochain": ["https://x"]})) diff --git a/bridge-sdk/tests/test_registry_compatibility.py b/bridge-sdk/tests/test_registry_compatibility.py index 0c4a16ee..9ce54672 100644 --- a/bridge-sdk/tests/test_registry_compatibility.py +++ b/bridge-sdk/tests/test_registry_compatibility.py @@ -200,3 +200,11 @@ def test_a_previous_prepared_checkpoint_recovers_without_network_or_signing(): result = recover(b, cp) assert result.next == 'resume' and cp['route']['registryVersion'] == PREVIOUS['version'] assert b.calls == [] and b.events == [] + + +def test_public_read_rpc_urls_do_not_change_legacy_fingerprints(): + from aleo_bridge._registry_compatibility import route_fingerprint + r = DEFAULT_REGISTRY + bare = Registry(r.version, r.chains(), r.assets(), r.routes(include_unavailable=True)) + for route in LEGACY['routes']: + assert route_fingerprint(r, route['id']) == route_fingerprint(bare, route['id']) From 4455ffc17190a8c94c43476f10656f6747cf4185 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:07:00 -0400 Subject: [PATCH 07/13] feat(bridge): optional per-chain RPC URLs in the profile --- bridge-sdk/python/aleo_bridge/client.py | 15 +++++++++--- bridge-sdk/python/aleo_bridge/profile.py | 30 +++++++++++++++++++++++- bridge-sdk/tests/test_evm_connections.py | 20 ++++++++++++++++ bridge-sdk/tests/test_profile.py | 16 +++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 43f3d22f..a505bb53 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -520,14 +520,23 @@ def from_env(cls, **overrides: Any) -> "Bridge": def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: str | None = None, ethereum: Any = None, solana: Any = None, evm: Any = None) -> "Bridge": """The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when - creating. Side-chain connections come from the arguments or the same env variables as ``from_env``.""" + 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} 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")) evm = _evm_connections.from_env(evm, environment=aleo.network_name) - bridge = cls(aleo, ethereum=ethereum if ethereum is not None else - ethereum_from_env() if _evm_connections.needs_legacy_environment(evm) else None, evm=evm, + if ethereum is None and _evm_connections.needs_legacy_environment(evm): + ethereum = ethereum_from_env() + ethereum_chain = "ethereum" if aleo.network_name == "mainnet" else "sepolia" + for chain_id, url in getattr(profile, "rpc_urls", {}).items(): # lowest priority: profile URLs + if chain_id == ethereum_chain: + if ethereum is None and ethereum_chain not in evm: + ethereum = Ethereum(url) + elif chain_id not in evm: + evm[chain_id] = Ethereum(url) + bridge = cls(aleo, ethereum=ethereum, evm=evm, solana=solana if solana is not None else solana_from_env(), checkpoints=_checkpoints_for_profile(profile)) bridge.profile = profile diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py index 40d749c4..7bc989b4 100644 --- a/bridge-sdk/python/aleo_bridge/profile.py +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -7,6 +7,7 @@ import json import os +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -51,9 +52,11 @@ def default_home() -> Path: @classmethod def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mainnet", - endpoint: str = DEFAULT_ENDPOINT) -> "Profile": + endpoint: str = DEFAULT_ENDPOINT, rpc_urls: "Mapping[str, str] | None" = None) -> "Profile": if network not in NETWORKS: raise ConfigurationError(f"Profile network must be one of {NETWORKS}, got {network!r}") + for chain_id, url in (rpc_urls or {}).items(): + _check_rpc_url(chain_id, url) home_path = Path(home).expanduser() if home is not None else cls.default_home() path = home_path / _PROFILE if path.exists(): @@ -67,6 +70,8 @@ def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mai 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} + if rpc_urls: + data["rpc_urls"] = dict(rpc_urls) try: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: @@ -103,5 +108,28 @@ def checkpoint_dir(self) -> Path: """Directory plan 4 binds as ``FileCheckpointStore``; created with the profile.""" return self.home / _CHECKPOINTS + @property + def rpc_urls(self) -> dict[str, str]: + """Read-only RPC URLs per registry chain id, used by ``Bridge.from_profile`` for chains with no other + connection (pause-switch reads on the far side of an Aleo-origin route). URLs only; never a key.""" + return dict(self._data.get("rpc_urls", {})) + + def set_rpc_url(self, chain_id: str, url: str) -> None: + """Persist *url* for *chain_id* (explicit ``ethereum=``/``evm=`` arguments and env variables still win).""" + _check_rpc_url(chain_id, url) + self._data.setdefault("rpc_urls", {})[chain_id] = url + path = self.home / _PROFILE + tmp = path.with_suffix(".json.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + handle.write(json.dumps(self._data, indent=1)) + os.replace(tmp, path) + path.chmod(0o600) + + +def _check_rpc_url(chain_id: str, url: str) -> None: + if not isinstance(url, str) or not url.startswith(("https://", "http://")): + raise ConfigurationError(f"RPC URL for {chain_id!r} must start with https:// (or http:// for a local node), got {url!r}") + __all__ = ["DEFAULT_ENDPOINT", "Profile"] diff --git a/bridge-sdk/tests/test_evm_connections.py b/bridge-sdk/tests/test_evm_connections.py index 1cf41424..4d48e57d 100644 --- a/bridge-sdk/tests/test_evm_connections.py +++ b/bridge-sdk/tests/test_evm_connections.py @@ -107,3 +107,23 @@ def test_testnet_ignores_automatically_loaded_mainnet_connections(factory, varia b = Bridge.from_env() if factory == 'env' else Bridge.from_profile() assert b.environment == 'testnet' assert b._evm_connections == {} + + +def test_profile_rpc_urls_fill_missing_connections_but_never_override(monkeypatch): + from types import SimpleNamespace + for var in ('ETHEREUM_RPC_URL', 'EVM_PRIVATE_KEY', 'BRIDGE_EVM_PRIVATE_KEY', 'BRIDGE_LIVE_ETHEREUM_RPC_URL', + 'BASE_RPC_URL', 'ARBITRUM_RPC_URL'): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv('ARC_RPC_URL', 'https://env-arc.invalid') + explicit_base = Ethereum(w3=fake_web3()) + profile = SimpleNamespace(endpoint='https://unused.invalid', network='mainnet', private_key='fake', + rpc_urls={'ethereum': 'https://profile-eth.invalid', 'arc': 'https://profile-arc.invalid', + 'base': 'https://profile-base.invalid'}) + with patch('aleo_bridge.client.build_aleo', return_value=FakeAleo(mappings=default_mappings())), \ + patch('aleo_bridge.client.Profile.load_or_create', return_value=profile), \ + patch('aleo_bridge.client._checkpoints_for_profile', return_value=None): + b = Bridge.from_profile(evm={'base': explicit_base}) + assert b.ethereum is not None and b.ethereum.can_sign is False + assert b.ethereum.w3.provider.endpoint_uri == 'https://profile-eth.invalid' # profile fills the gap + assert b._evm_connections['arc'].w3.provider.endpoint_uri == 'https://env-arc.invalid' # env wins + assert b._evm_connections['base'] is explicit_base # explicit wins diff --git a/bridge-sdk/tests/test_profile.py b/bridge-sdk/tests/test_profile.py index 30d61ea4..e805f253 100644 --- a/bridge-sdk/tests/test_profile.py +++ b/bridge-sdk/tests/test_profile.py @@ -74,3 +74,19 @@ def racing_initial_key(network): assert profile.address == winner_address assert json.loads((home / "profile.json").read_text())["address"] == winner_address assert stat.S_IMODE(os.stat(home).st_mode) == 0o700 + + +def test_rpc_urls_persist_and_old_profiles_load_without_them(tmp_path, monkeypatch): + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + p = Profile.load_or_create(tmp_path / "h", rpc_urls={"ethereum": "https://eth.example"}) + assert p.rpc_urls == {"ethereum": "https://eth.example"} + p.set_rpc_url("arc", "https://arc.example") + assert Profile.load_or_create(tmp_path / "h").rpc_urls == {"ethereum": "https://eth.example", "arc": "https://arc.example"} + assert stat.S_IMODE(os.stat(tmp_path / "h" / "profile.json").st_mode) == 0o600 + (tmp_path / "old").mkdir() + (tmp_path / "old" / "profile.json").write_text(json.dumps( + {"address": p.address, "private_key": p.private_key, "network": "mainnet"})) + assert Profile.load_or_create(tmp_path / "old").rpc_urls == {} + with pytest.raises(ConfigurationError, match="https"): + p.set_rpc_url("base", "ftp://nope") From 1d9aa841d09d413a95cd07869a59e1e6c9fdad4f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:11:09 -0400 Subject: [PATCH 08/13] feat(bridge): Bridge.pause_status reads every switch on both ends of a route --- bridge-sdk/python/aleo_bridge/_sealevel.py | 27 +++ bridge-sdk/python/aleo_bridge/client.py | 18 +- bridge-sdk/python/aleo_bridge/pause.py | 133 +++++++++++++- .../fixtures/sealevel-token-pda-bat.json | 7 + .../fixtures/sealevel-token-pda-sol.json | 7 + bridge-sdk/tests/test_pause_status.py | 165 ++++++++++++++++++ bridge-sdk/tests/test_sealevel_token_pda.py | 30 ++++ 7 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 bridge-sdk/tests/fixtures/sealevel-token-pda-bat.json create mode 100644 bridge-sdk/tests/fixtures/sealevel-token-pda-sol.json create mode 100644 bridge-sdk/tests/test_pause_status.py create mode 100644 bridge-sdk/tests/test_sealevel_token_pda.py diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 849948d5..c7a6af9c 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -105,6 +105,33 @@ def pubkey(self) -> str: return b58encode(self.take(32)) +def decode_hyperlane_token_remote_routers(data: bytes) -> dict[int, bytes]: + """``remote_routers`` of a warp program's ``HyperlaneToken`` account (hyperlane-monorepo 45c0988). + + Layout, Borsh little-endian, as ``AccountData>``: u8 initialized; u8 bump; 32 mailbox; + 32 mailbox_process_authority; u8 dispatch_authority_bump; u8 decimals; u8 remote_decimals; + Option owner; Option interchain_security_module; Option<(Pubkey, IgpType)> + interchain_gas_paymaster (enum tag u8 + Pubkey); HashMap destination_gas; HashMap + remote_routers. ``plugin_data`` and ``fee_config`` follow and are not decoded. A missing domain means the + operator un-enrolled that route — the Sealevel program has no pause flag of its own. + """ + try: + cursor = _Cursor(data) + if cursor.u8() != 1: + raise ConfigurationError("malformed Sealevel token account data: account is not initialized") + cursor.take(1 + 32 + 32 + 1 + 1 + 1) # bump, mailbox, process authority, bump, decimals x2 + for _ in range(2): # owner, ISM + if cursor.u8() == 1: + cursor.take(32) + if cursor.u8() == 1: # IGP: (Pubkey, enum tag, Pubkey) + cursor.take(32 + 1 + 32) + for _ in range(cursor.u32()): # destination_gas: u32 -> u64 + cursor.take(4 + 8) + return {cursor.u32(): cursor.take(32) for _ in range(cursor.u32())} + except ConfigurationError as exc: + raise ConfigurationError(f"malformed Sealevel token account data: {exc}") from exc + + def decode_igp_account(data: bytes) -> IgpAccount: """Decode the terminal ``Igp`` account (the ``inner`` of an OverheadIgp), SEALEVEL_NOTES §4.""" cursor = _Cursor(data) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index a505bb53..aac4378d 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -26,7 +26,7 @@ from .profile import DEFAULT_ENDPOINT, Profile from .registry import DEFAULT_REGISTRY, Asset, Chain, Registry, validate_registry from .sol import Solana, SolModule -from .types import BridgeStatus, ChainStatus, PrivacyReceipt +from .types import BridgeStatus, ChainStatus, PauseStatus, PrivacyReceipt from .units import format_decimal_amount, parse_decimal_amount from .xreserve import XReserveModule @@ -350,6 +350,22 @@ def routes(self, *, source_chain: str | None = None, source_asset: str | None = bridge_protocol=bridge_protocol, symbol=symbol, include_unavailable=include_unavailable, environment=self.environment) + def pause_status(self, route, *, sides=("source", "destination")) -> PauseStatus: + """Read every operator pause switch governing ``route`` (a ``Route``, its id, or a ``Plan``), live. + + 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 + 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 + configured connection is read over the profile's ``rpc_urls`` or the registry's public endpoints. + """ + from .pause import PauseReader + route_id = getattr(route, "route_id", None) or getattr(route, "id", None) or route + return PauseReader(self).status(self.registry.route(route_id), sides=tuple(sides)) + def quote(self, *, source_chain: str | None = None, source_asset: str | None = None, destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route=None, amount=None, amount_atomic=None, recipient: str, diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index c2a93142..4863fa1e 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -20,7 +20,10 @@ from typing import Any, Callable, Sequence, TypeVar from .errors import ConfigurationError -from .types import PauseSwitch +from .eth import Ethereum +from .registry import Asset, Chain, Route +from .sol import DEFAULT_SOLANA_RPC_URL, Solana +from .types import PauseStatus, PauseSwitch T = TypeVar("T") @@ -118,14 +121,19 @@ class _TreeTooDeep(Exception): def _evm_call(w3: Any, address: str, fn: str, *args: Any, sleep: Callable[[float], None]) -> Any: - """One view call through the retry loop. A revert is a ``ConfigurationError`` (the registry names a - contract that does not answer this getter), never retried.""" + """One view call through the retry loop. *w3* is a ``Web3`` or an ``attempt -> Web3`` callable (public + endpoints rotate per attempt). A revert is a ``ConfigurationError`` (the registry names a contract that + does not answer this getter), never retried.""" from .eth import _web3 from ._evm_abi import PAUSE_ABI web3 = _web3() - contract = w3.eth.contract(address=web3.Web3.to_checksum_address(address), abi=PAUSE_ABI) + checksummed = web3.Web3.to_checksum_address(address) + + def call(attempt: int) -> Any: + client = 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(lambda _attempt: getattr(contract.functions, fn)(*args).call(), sleep=sleep) + return read_with_retry(call, sleep=sleep) except web3.exceptions.ContractLogicError as exc: raise ConfigurationError(f"{fn}() on {address} reverted: {exc}") from exc @@ -201,5 +209,116 @@ def read_mailbox_ism_pauses(w3: Any, *, chain_id: str, side: str, mailbox: str, roots=("defaultIsm",), switch="PausableIsm.paused()", sleep=sleep) -__all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "ReadFailed", "read_aleo_flag", "read_aleo_remote_router", - "read_evm_paused", "read_mailbox_hook_pauses", "read_mailbox_ism_pauses", "read_with_retry", "rotate"] +# ── Solana ─────────────────────────────────────────────────────────────────── + +def read_solana_remote_router(solana: Any, *, chain_id: str, side: str, token_pda: str, domain: int, + sleep: Callable[[float], None] = time.sleep) -> PauseSwitch: + """The warp program's enrolled router for *domain* in its token PDA; a missing account or entry is paused.""" + from ._sealevel import decode_hyperlane_token_remote_routers + from .sol import SolModule + switch = f"remote_routers[{domain}]" + reader = SolModule(None, solana) + try: + data = read_with_retry(lambda _attempt: reader._account_data(token_pda), sleep=sleep) + except ReadFailed as exc: + return PauseSwitch(chain_id, side, token_pda, switch, "unknown", str(exc)) + router = None if data is None else decode_hyperlane_token_remote_routers(data).get(domain) + return PauseSwitch(chain_id, side, token_pda, switch, "open" if router else "paused", + None if router is None else "0x" + router.hex()) + + +# ── Per-route assembly ─────────────────────────────────────────────────────── + +SIDES = ("source", "destination") + + +class PauseReader: + """Reads every switch governing a route, on the requested sides, in one pass. Nothing is cached.""" + + def __init__(self, bridge: Any, *, sleep: Callable[[float], None] = time.sleep) -> None: + self._bridge = bridge + self._sleep = sleep + + # -- connections -- + def evm_w3(self, chain_id: str) -> Any: + """A ``Web3`` for *chain_id*, or an ``attempt -> Web3`` callable over the registry's public read + endpoints: configured connection (constructor, env or profile) first, public endpoints last.""" + bridge, registry = self._bridge, self._bridge.registry + conn = bridge._evm_connections.get(chain_id) + if conn is not None: + return conn.w3 + urls = registry.public_read_rpc_urls(chain_id) + if not urls: + raise ConfigurationError( + f"No RPC connection for {chain_id} to read its pause switches: pass ethereum=/evm= to Bridge(...), " + f"save one with profile.set_rpc_url({chain_id!r}, url), or set {chain_id.upper()}_RPC_URL") + return lambda attempt: Ethereum(rotate(urls, attempt)).w3 + + def _solana(self) -> Any: + return self._bridge.solana if self._bridge.solana is not None else Solana(DEFAULT_SOLANA_RPC_URL) + + # -- 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 == "hyperlane": + program = asset.locator.value if asset.locator is not None else route.meta_str("aleoRouterProgram") + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=program, mapping="pause", + key="true", sleep=self._sleep)) + out.append(read_aleo_remote_router(bridge, chain_id=asset.chain_id, side=side, program=program, + domain=far_chain.protocol_domains["hyperlane"], sleep=self._sleep)) + if asset.privacy is not None and asset.privacy.kind == "arc20": + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=asset.privacy.program, + mapping="pause__", key="false", sleep=self._sleep)) + elif route.protocol == "xreserve": + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=route.meta_str("bridgeProgram"), + mapping="paused", key="true", sleep=self._sleep)) + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=route.meta_str("remoteToken"), + mapping="pause", key="true", sleep=self._sleep)) + return out + + def _evm(self, side: str, route: Route, chain: Chain, far_chain: Chain) -> list[PauseSwitch]: + w3 = self.evm_w3(chain.id) + 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"), + far_domain=far_chain.protocol_domains["hyperlane"], sleep=self._sleep) + key = "xReserveContract" if route.protocol == "xreserve" else "messageTransmitter" + return [read_evm_paused(w3, chain_id=chain.id, side=side, address=route.meta_str(key), sleep=self._sleep)] + + def _solana_switches(self, side: str, route: Route, asset: Asset, far_asset: Asset) -> list[PauseSwitch]: + registry = self._bridge.registry + if side == "source": + token_pda = route.meta_str("tokenPda") + else: # the paired Solana -> Aleo route carries the deployment metadata + paired = [r for r in registry.routes(bridge_protocol="hyperlane", include_unavailable=True, + environment=self._bridge.environment) + if r.source_asset_id == asset.id and r.destination_asset_id == far_asset.id] + if not paired: + raise ConfigurationError(f"No Solana-origin route pairs with {route.id} to locate its token PDA") + token_pda = paired[0].meta_str("tokenPda") + aleo_domain = registry.chain(far_asset.chain_id).protocol_domains["hyperlane"] + return [read_solana_remote_router(self._solana(), chain_id=asset.chain_id, side=side, token_pda=token_pda, + domain=aleo_domain, sleep=self._sleep)] + + def status(self, route: Route, *, sides: Sequence[str] = SIDES) -> PauseStatus: + registry = self._bridge.registry + assets = {"source": registry.asset(route.source_asset_id), "destination": registry.asset(route.destination_asset_id)} + chains = {side: registry.chain(a.chain_id) for side, a in assets.items()} + switches: list[PauseSwitch] = [] + for side in SIDES: + if side not in sides: + continue + far = "destination" if side == "source" else "source" + family = chains[side].family + if family == "aleo": + switches += self._aleo(side, route, assets[side], chains[far]) + elif family == "evm": + switches += self._evm(side, route, chains[side], chains[far]) + elif family == "solana": + switches += self._solana_switches(side, route, assets[side], assets[far]) + return PauseStatus(route.id, tuple(switches)) + + +__all__ = ["BACKOFF_SECONDS", "IMMEDIATE_RETRIES", "PauseReader", "ReadFailed", "SIDES", "read_aleo_flag", + "read_aleo_remote_router", "read_evm_paused", "read_mailbox_hook_pauses", "read_mailbox_ism_pauses", + "read_solana_remote_router", "read_with_retry", "rotate"] diff --git a/bridge-sdk/tests/fixtures/sealevel-token-pda-bat.json b/bridge-sdk/tests/fixtures/sealevel-token-pda-bat.json new file mode 100644 index 00000000..9a5dd4db --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-token-pda-bat.json @@ -0,0 +1,7 @@ +{ + "_source": "api.mainnet-beta.solana.com getAccountInfo (base64); layout hyperlane-monorepo 45c0988 hyperlane-sealevel-token HyperlaneToken", + "pubkey": "DLMYtaKyG5w7djyib9XhrbkiSvMfV4AmniQHFnAetsZB", + "slot": 463380351, + "owner": "7CJFBsNC49upnVfMga2gj53deAjuuVchdceJQrJg5oA5", + "data_base64": "Af/CNlAM8QxAnfVmsUFbmWeckoUHK/hW/jEbPMG7lGJ5pWUpg90yXBITuVVzxwtOLbhYj44vo9wmjGNOr2+7HnkJ/wgSAbJozqeeSI7d9vs9KgqvWxeBSUHax7QFFDDySWZpjdi7AAGe7IpE4dCXOqBVjVfv38itLCrdcznl6pJFtAKB6bScRQGQ508C725EsLr5s1vwyOMe+xqezJJdKbLverxaY6pTdgIAAAABAAAAoAkBAAAAAABvZWxhYOoAAAAAAAACAAAAAQAAAAAAAAAAAAAAAAAAAFFuFW6YcXXXRhTMK8lg8UimEPCzb2VsYZ6tLf8xW8IxukgLsH8Ii+w3JueNoMbuc661PZMegB0SBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKnG9RM0TGMMBg5IeOiWmKl1c0Hmjg8Y8LMKPbxZWSrTdwWKLJWZ4Exi7l4WS/pyPKqaEQUSodeXvkP0RrCrcPvK/f8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/bridge-sdk/tests/fixtures/sealevel-token-pda-sol.json b/bridge-sdk/tests/fixtures/sealevel-token-pda-sol.json new file mode 100644 index 00000000..a46c5e0c --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-token-pda-sol.json @@ -0,0 +1,7 @@ +{ + "_source": "api.mainnet-beta.solana.com getAccountInfo (base64); layout hyperlane-monorepo 45c0988 hyperlane-sealevel-token HyperlaneToken", + "pubkey": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "slot": 463380350, + "owner": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "data_base64": "Af/CNlAM8QxAnfVmsUFbmWeckoUHK/hW/jEbPMG7lGJ5pRP9fO7x6rvZtI2ejoCj2V1RwYriP78rQ19yVK2XLJoc/gkJAYhdGg5L9NwMTtjI7Bji2X9sFJVYNnYrCnWBHXJyE0fQAAGe7IpE4dCXOqBVjVfv38itLCrdcznl6pJFtAKB6bScRQGQ508C725EsLr5s1vwyOMe+xqezJJdKbLverxaY6pTdgIAAADnAwAAAPoAAAAAAABvZWxhAPoAAAAAAAACAAAA5wMAAAAAAAAAAAAAAAAAAO4xR97t/Axt3Xxl1On/mkhjK+ZFb2VsYe9eJb2ILx1p5sEGcpszraUg4Pjxi7FMDCoI2P/urZcN/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +} diff --git a/bridge-sdk/tests/test_pause_status.py b/bridge-sdk/tests/test_pause_status.py new file mode 100644 index 00000000..1f9be14c --- /dev/null +++ b/bridge-sdk/tests/test_pause_status.py @@ -0,0 +1,165 @@ +"""``Bridge.pause_status``: every switch on both ends of a route, per protocol family, read live.""" +import base64 +import json +from pathlib import Path + +import pytest +import requests + +from aleo_bridge import Bridge, Ethereum, Solana +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.lifecycle import prepare +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry +from tests.conftest import FakeAleo +from tests.fakes.fake_solana import FakeSolanaClient +from tests.fakes.fake_web3 import fake_web3, make_bridge +from tests.test_pause_evm import ISM_PAUSABLE, MAILBOX, XRESERVE, _hook_tree, addr, boolean, sel, u8 + +FIXTURES = Path(__file__).parent / "fixtures" +ALEO_DOMAIN = DEFAULT_REGISTRY.chain("aleo").protocol_domains["hyperlane"] +SOL_PDA = "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW" +TRANSMITTER = "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64" +ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +EVM_RECIPIENT = "0x00000000000000000000000000000000000000ee" + + +def _ism_tree(paused: bool) -> dict: + return {(MAILBOX, sel("defaultIsm()")): addr(ISM_PAUSABLE), (ISM_PAUSABLE, sel("moduleType()")): u8(6), + (ISM_PAUSABLE, sel("paused()")): boolean(paused)} + + +def _warp(program: str, far_domain: int, *, paused: bool = False, enrolled: bool = True) -> dict: + routers = {f"{far_domain}u32": "{ domain: 1u32, recipient: [0u8], gas: 1u128 }"} if enrolled else {} + return {program: {"pause": {"true": str(paused).lower()}, "remote_routers": routers, "app_metadata": {}}} + + +def _sol_pda_bytes() -> bytes: + return base64.b64decode(json.loads((FIXTURES / "sealevel-token-pda-sol.json").read_text())["data_base64"]) + + +def _keyed(status): + return {(s.chain_id, s.side, s.switch): s.state for s in status.switches} + + +def test_aleo_to_ethereum_hyperlane_reads_both_ends(): + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_ism_tree(False))), + mappings={**_warp("hyp_warp_token_eth_v2.aleo", 1), "arc20_eth.aleo": {"pause__": {}}}) + status = b.pause_status("hyperlane:aleo/eth->ethereum/eth") + assert _keyed(status) == {("aleo", "source", "pause[true]"): "open", ("aleo", "source", "remote_routers[1u32]"): "open", + ("aleo", "source", "pause__[false]"): "open", + ("ethereum", "destination", "PausableIsm.paused()"): "open"} + assert not status.paused and not status.unknown + + +def test_ethereum_to_aleo_hyperlane_source_hook_tree_and_destination_aleo(): + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_hook_tree(False))), + mappings={**_warp("hyp_warp_token_eth_v2.aleo", 1, paused=True), "arc20_eth.aleo": {"pause__": {}}}) + status = b.pause_status("hyperlane:ethereum/eth->aleo/eth") + keyed = _keyed(status) + assert keyed[("ethereum", "source", "PausableHook.paused()")] == "open" + assert keyed[("aleo", "destination", "pause[true]")] == "paused" and status.paused + assert "hyp_warp_token_eth_v2.aleo" in status.reason + + +def test_aleo_to_solana_uses_the_paired_routes_token_pda(): + sol = Solana(client=FakeSolanaClient(accounts={SOL_PDA: _sol_pda_bytes()})) + b = make_bridge(ethereum=None, mappings={**_warp("hyp_warp_token_sol_v2.aleo", 1399811149), "arc20_sol.aleo": {"pause__": {}}}) + b.solana = sol + status = b.pause_status("hyperlane:aleo/sol->solana/sol") + assert _keyed(status)[("solana", "destination", f"remote_routers[{ALEO_DOMAIN}]")] == "open" and not status.paused + b.solana = Solana(client=FakeSolanaClient(accounts={})) # account gone → un-enrolled + assert _keyed(b.pause_status("hyperlane:aleo/sol->solana/sol"))[("solana", "destination", f"remote_routers[{ALEO_DOMAIN}]")] == "paused" + + +def test_solana_to_aleo(): + b = make_bridge(ethereum=None, mappings={**_warp("hyp_warp_token_sol_v2.aleo", 1399811149), "arc20_sol.aleo": {"pause__": {}}}) + b.solana = Solana(client=FakeSolanaClient(accounts={SOL_PDA: _sol_pda_bytes()})) + keyed = _keyed(b.pause_status("hyperlane:solana/sol->aleo/sol")) + assert keyed[("solana", "source", f"remote_routers[{ALEO_DOMAIN}]")] == "open" + assert keyed[("aleo", "destination", "pause[true]")] == "open" and keyed[("aleo", "destination", "remote_routers[1399811149u32]")] == "open" + + +def test_xreserve_both_directions_and_arc(): + aleo = {"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {"true": "false"}}} + eth = Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): boolean(False)})) + arc = Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): boolean(True)})) + b = make_bridge(ethereum=eth, evm={"arc": arc}, mappings=aleo) + out = _keyed(b.pause_status("xreserve:aleo/usdcx->ethereum/usdc")) + assert out == {("aleo", "source", "paused[true]"): "open", ("aleo", "source", "pause[true]"): "open", + ("ethereum", "destination", "paused()"): "open"} + inbound = _keyed(b.pause_status("xreserve:ethereum/usdc->aleo/usdcx")) + assert inbound == {("ethereum", "source", "paused()"): "open", ("aleo", "destination", "paused[true]"): "open", + ("aleo", "destination", "pause[true]"): "open"} + assert b.pause_status("xreserve:aleo/usdcx->arc/usdc").paused # Arc's XReserve is paused in this fake + + +def test_cctp_reads_message_transmitter_on_both_chains(): + eth = Ethereum(w3=fake_web3(calls={(TRANSMITTER, sel("paused()")): boolean(False)})) + arc = Ethereum(w3=fake_web3(calls={(TRANSMITTER, sel("paused()")): boolean(True)})) + b = make_bridge(ethereum=eth, evm={"arc": arc}) + status = b.pause_status("cctp:ethereum/usdc->arc/usdc") + assert _keyed(status) == {("ethereum", "source", "paused()"): "open", ("arc", "destination", "paused()"): "paused"} + + +def test_sides_destination_only_skips_source_reads(): + w3 = fake_web3(calls=_hook_tree(False)) + b = make_bridge(ethereum=Ethereum(w3=w3), mappings={**_warp("hyp_warp_token_eth_v2.aleo", 1), "arc20_eth.aleo": {"pause__": {}}}) + status = b.pause_status("hyperlane:ethereum/eth->aleo/eth", sides=("destination",)) + assert {s.side for s in status.switches} == {"destination"} and w3.provider.call_log == [] + + +def test_destination_evm_falls_back_to_registry_public_rpc(monkeypatch): + urls = [] + + def factory(url): + urls.append(url) + return Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): boolean(False)})) + monkeypatch.setattr("aleo_bridge.pause.Ethereum", factory) + b = make_bridge(ethereum=None, mappings={"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {}}}) + status = b.pause_status("xreserve:aleo/usdcx->ethereum/usdc") + assert not status.paused and not status.unknown + assert urls == [DEFAULT_REGISTRY.public_read_rpc_urls("ethereum")[0]] + + +def test_public_rpc_rotation_on_transient_errors(monkeypatch): + urls = [] + public = DEFAULT_REGISTRY.public_read_rpc_urls("ethereum") + assert len(public) >= 2 + + def factory(url): + urls.append(url) + scripted = requests.ConnectionError("down") if url == public[0] else boolean(False) + return Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): scripted})) + monkeypatch.setattr("aleo_bridge.pause.Ethereum", factory) + b = make_bridge(ethereum=None, mappings={"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {}}}) + status = b.pause_status("xreserve:aleo/usdcx->ethereum/usdc") # first retry is immediate, so no real wait + assert not status.unknown and urls == [public[0], public[1]] + + +def test_no_connection_and_no_public_urls_is_a_configuration_error(): + r = DEFAULT_REGISTRY + bare = Registry(r.version, r.chains(), r.assets(), r.routes(include_unavailable=True)) + b = Bridge(FakeAleo(mappings={"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {}}}), registry=bare) + with pytest.raises(ConfigurationError, match="ethereum=.*set_rpc_url.*ETHEREUM_RPC_URL"): + b.pause_status("xreserve:aleo/usdcx->ethereum/usdc") + + +def test_bridge_pause_status_accepts_route_id_route_and_plan(): + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_ism_tree(False))), + mappings={**_warp("hyp_warp_token_eth_v2.aleo", 1), "arc20_eth.aleo": {"pause__": {}}}) + route = b.registry.route("hyperlane:aleo/eth->ethereum/eth") + plan = prepare(b.registry, source_chain="aleo", source_asset="eth", destination_chain="ethereum", destination_asset="eth", + amount="0.000000000000000001", recipient=EVM_RECIPIENT) + assert b.pause_status(route.id) == b.pause_status(route) == b.pause_status(plan) + + +def test_pause_status_never_caches(monkeypatch): + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_ism_tree(False))), + mappings={**_warp("hyp_warp_token_eth_v2.aleo", 1), "arc20_eth.aleo": {"pause__": {}}}) + reads = [] + original = b.mapping_value + monkeypatch.setattr(b, "mapping_value", lambda *a: (reads.append(a), original(*a))[1]) + b.pause_status("hyperlane:aleo/eth->ethereum/eth") + first = len(reads) + b.pause_status("hyperlane:aleo/eth->ethereum/eth") + assert first == 3 and len(reads) == 2 * first diff --git a/bridge-sdk/tests/test_sealevel_token_pda.py b/bridge-sdk/tests/test_sealevel_token_pda.py new file mode 100644 index 00000000..5c3fab51 --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_token_pda.py @@ -0,0 +1,30 @@ +"""Known-answer test: the Sealevel ``HyperlaneToken`` account's ``remote_routers`` map decodes from live mainnet dumps.""" +import base64 +import json +from pathlib import Path + +import pytest + +from aleo_bridge._sealevel import decode_hyperlane_token_remote_routers +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.registry import DEFAULT_REGISTRY + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.mark.parametrize("name", ["sol", "bat"]) +def test_decodes_remote_routers_and_finds_the_aleo_entry(name): + fx = json.loads((FIXTURES / f"sealevel-token-pda-{name}.json").read_text()) + routers = decode_hyperlane_token_remote_routers(base64.b64decode(fx["data_base64"])) + aleo_domain = DEFAULT_REGISTRY.chain("aleo").protocol_domains["hyperlane"] + assert aleo_domain in routers and len(routers[aleo_domain]) == 32 and routers[aleo_domain] != bytes(32) + assert all(len(v) == 32 for v in routers.values()) + # The entry is the Aleo warp program's address as bytes32. Pinning its exact value is left to the + # builder if the aleo facade exposes a program-address helper; presence and shape are the KAT here. + + +def test_short_or_corrupt_data_is_a_configuration_error(): + with pytest.raises(ConfigurationError, match="malformed Sealevel token account"): + decode_hyperlane_token_remote_routers(b"\x01" + b"\x00" * 10) + with pytest.raises(ConfigurationError, match="malformed Sealevel token account"): + decode_hyperlane_token_remote_routers(b"\x00" + b"\x00" * 200) # not initialized From 1ae1ae1ba4f48826b6763c2a223d94a969b98f5b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:15:04 -0400 Subject: [PATCH 09/13] fix(bridge): read the pause convention each Aleo program declares (pause, paused or pause__) --- bridge-sdk/python/aleo_bridge/pause.py | 34 ++++++++++++++++++++++---- bridge-sdk/tests/test_pause_status.py | 17 +++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index 4863fa1e..026fdfb7 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -85,6 +85,24 @@ def declared(_attempt: int) -> list[str]: return read_with_retry(lambda _attempt: bridge.mapping_value(program, mapping, key), sleep=sleep) +PAUSE_MAPPINGS = (("pause", "true"), ("paused", "true"), ("pause__", "false")) # (mapping, key) conventions seen on chain + + +def declared_pause_mapping(bridge: Any, program: str, *, sleep: Callable[[float], None] = time.sleep) -> tuple[str, str] | None: + """Which pause convention *program* declares: ``pause[true]`` (ETH/WBTC/SOL warp, usdcx_stablecoin), + ``paused[true]`` (usdcx_bridge_v2) or the ARC-20 ``pause__[false]`` (BAT/USDG/ZEC warp, arc20_*/shield_arc22_* + tokens); None when it has no pause switch at all.""" + from aleo.facade.errors import ProgramNotFound + + def declared(_attempt: int) -> list[str]: + try: + return list(bridge.program(program).mappings()) + except ProgramNotFound: + return [] + names = read_with_retry(declared, sleep=sleep) + return next(((m, k) for m, k in PAUSE_MAPPINGS if m in names), None) + + def read_aleo_flag(bridge: Any, *, chain_id: str, side: str, program: str, mapping: str, key: str, sleep: Callable[[float], None] = time.sleep) -> PauseSwitch: """A boolean pause mapping (``pause[true]``, ``paused[true]``, the ARC-20 ``pause__[false]``): ``true`` is paused.""" @@ -262,13 +280,19 @@ def _aleo(self, side: str, route: Route, asset: Asset, far_chain: Chain) -> list bridge, out = self._bridge, [] if route.protocol == "hyperlane": program = asset.locator.value if asset.locator is not None else route.meta_str("aleoRouterProgram") - out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=program, mapping="pause", - key="true", sleep=self._sleep)) + convention = declared_pause_mapping(bridge, program, sleep=self._sleep) + if convention is None: + raise ConfigurationError(f"{program} declares no pause mapping ({[m for m, _ in PAUSE_MAPPINGS]}); " + "every reviewed warp program has one, so this deployment is not the reviewed one") + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=program, + mapping=convention[0], key=convention[1], sleep=self._sleep)) out.append(read_aleo_remote_router(bridge, chain_id=asset.chain_id, side=side, program=program, domain=far_chain.protocol_domains["hyperlane"], sleep=self._sleep)) - if asset.privacy is not None and asset.privacy.kind == "arc20": - out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=asset.privacy.program, - mapping="pause__", key="false", sleep=self._sleep)) + if asset.privacy is not None: # the ARC-20 / shield ARC-22 token the warp mints and burns + token = declared_pause_mapping(bridge, asset.privacy.program, sleep=self._sleep) + if token is not None: + out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=asset.privacy.program, + mapping=token[0], key=token[1], sleep=self._sleep)) elif route.protocol == "xreserve": out.append(read_aleo_flag(bridge, chain_id=asset.chain_id, side=side, program=route.meta_str("bridgeProgram"), mapping="paused", key="true", sleep=self._sleep)) diff --git a/bridge-sdk/tests/test_pause_status.py b/bridge-sdk/tests/test_pause_status.py index 1f9be14c..92104b50 100644 --- a/bridge-sdk/tests/test_pause_status.py +++ b/bridge-sdk/tests/test_pause_status.py @@ -163,3 +163,20 @@ def test_pause_status_never_caches(monkeypatch): first = len(reads) b.pause_status("hyperlane:aleo/eth->ethereum/eth") assert first == 3 and len(reads) == 2 * first + + +def test_warp_program_pause_mapping_follows_what_it_declares(): + """BAT/USDG/ZEC v2 warp programs and the shield ARC-22 tokens declare the ARC-20 style ``pause__[false]``; + ETH/WBTC/SOL warp programs declare ``pause[true]``. The reader follows the declaration.""" + bat, shield = "hyp_warp_token_bat_v2.aleo", "shield_arc22_bat.aleo" + mappings = {bat: {"pause__": {"false": "true"}, "remote_routers": {"1u32": "{ domain: 1u32 }"}, "app_metadata": {}}, + shield: {"pause__": {}}} + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_ism_tree(False))), mappings=mappings) + by_program = {(s.locator, s.switch): s.state for s in b.pause_status("hyperlane:aleo/bat->ethereum/bat").switches} + assert by_program[(bat, "pause__[false]")] == "paused" and by_program[(shield, "pause__[false]")] == "open" + assert (bat, "pause[true]") not in by_program + # a warp program with no pause mapping at all is a configuration error, never "open" + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_ism_tree(False))), + mappings={bat: {"remote_routers": {"1u32": "x"}}, shield: {}}) + with pytest.raises(ConfigurationError, match="declares no pause mapping"): + b.pause_status("hyperlane:aleo/bat->ethereum/bat") From 369044235f750330c7bb9d22fb474590e5ebd7c1 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:20:00 -0400 Subject: [PATCH 10/13] feat(bridge): quote, execute, resume and complete refuse paused or unreadable routes --- bridge-sdk/AGENTS.md | 3 +- bridge-sdk/python/aleo_bridge/AGENTS.md | 3 +- bridge-sdk/python/aleo_bridge/lifecycle.py | 26 +++++- bridge-sdk/python/aleo_bridge/pause.py | 2 + bridge-sdk/tests/conftest.py | 30 ++++++- bridge-sdk/tests/fakes/fake_bridge.py | 17 +++- bridge-sdk/tests/fakes/fake_web3.py | 6 ++ bridge-sdk/tests/test_pause_gate.py | 98 ++++++++++++++++++++++ 8 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 bridge-sdk/tests/test_pause_gate.py diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index fb660182..0d84a32f 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -46,7 +46,8 @@ Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None, evm: 'Any' = None) -> "'Bridge'"` The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when -creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. +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``. ### `status(self) -> 'BridgeStatus'` diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index fb660182..0d84a32f 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -46,7 +46,8 @@ Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None, evm: 'Any' = None) -> "'Bridge'"` The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when -creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. +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``. ### `status(self) -> 'BridgeStatus'` diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 2bf6e99f..98240070 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -19,7 +19,7 @@ import re import time from dataclasses import dataclass, replace -from typing import Any, Callable, cast +from typing import Any, Callable, Sequence, cast from . import _sealevel from ._calls import is_duplicate_submission @@ -96,6 +96,23 @@ def _require_active(route: Route) -> None: "(bridge.registry.routes()).") +def _require_unpaused(bridge, route: Route, *, sides: Sequence[str] = ("source", "destination")) -> None: + """Read the operator pause switches on *sides* of *route* right now and refuse on paused or unreadable. + + Fail closed: a switch that could not be read after retries blocks exactly like a set one — the SDK + never commits funds on a guess. Both raise ``RouteUnavailableError`` subclasses carrying ``.status``. + """ + status = bridge.pause_status(route, sides=tuple(sides)) + if status.paused: + raise RoutePausedError( + f"Route {route.id} is paused: {status.reason}. Wait for the operator to unpause; " + "bridge.pause_status() shows every switch.", status=status) + if status.unknown: + raise PauseCheckError( + f"Could not read a pause switch on {route.id}: {status.reason}. The SDK never proceeds on an " + "unreadable switch; retry later or point the client at another endpoint.", status=status) + + # ── prepare ─────────────────────────────────────────────────────────────────── def select_route(registry: Registry, *, source_chain: str | None = None, source_asset: str | None = None, @@ -237,6 +254,7 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = recipient=recipient, sender=sender, mint_mode=mint_mode, cctp=cctp) resolved = resolve_route(bridge.registry, plan) _require_active(resolved.route) + _require_unpaused(bridge, resolved.route) family = resolved.source_chain.family if plan.protocol == "cctp": @@ -581,6 +599,7 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin """ resolved = resolve_route(bridge.registry, plan) _require_active(resolved.route) + _require_unpaused(bridge, resolved.route) family = resolved.source_chain.family store = getattr(bridge, "checkpoints", None) if plan.protocol == "cctp": @@ -1424,6 +1443,7 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, "SOURCE_SUBMISSION_PENDING); call wait() or recover() to refresh it") resolved = resolve_route(bridge.registry, plan) _require_active(resolved.route) + _require_unpaused(bridge, resolved.route) # funds are not committed yet; a proved tx into a pause burns its fee _check_receipt(plan, receipt) emit = _Emitter(bridge, plan, on_checkpoint) state = receipt.protocol_state @@ -1545,6 +1565,7 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, """ if progress.plan.protocol == "cctp": plan = progress.plan + _require_unpaused(bridge, resolve_route(bridge.registry, plan).route, sides=("destination",)) emit = _Emitter(bridge, plan, on_checkpoint) receipt = bridge.cctp.complete(plan, progress.receipt, manual_mint=manual_mint, on_checkpoint=emit) emit(receipt) @@ -1561,7 +1582,10 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, # DESTINATION_ACTION_REQUIRED the USDC is already deposited on Ethereum, and refusing the mint # because the registry has since parked the route would strand it. The proving path still hits # XReserveModule's own availability check; a rebroadcast of already-proved bytes needs none. + # The pause gate does run, destination side only: the deposit is committed, so a source-side pause has + # no bearing, but a paused usdcx_bridge_v2 / usdcx_stablecoin would reject the mint and burn its fee. resolved = resolve_route(bridge.registry, plan) + _require_unpaused(bridge, resolved.route, sides=("destination",)) _check_receipt(plan, receipt) action = receipt.next_action or {} if (receipt.status is not Status.DESTINATION_ACTION_REQUIRED diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index 026fdfb7..4f95fdae 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -172,6 +172,8 @@ def _walk(w3: Any, address: str, *, far_domain: int, kind: str, depth: int, slee pausable member. Routing nodes resolve *far_domain*; aggregation nodes expand their members.""" if depth > TREE_DEPTH_LIMIT: raise _TreeTooDeep(address) + if int(address, 16) == 0: + return # nothing configured at this slot (no hook / no ISM for the domain) if kind == "hook": node_type = _evm_call(w3, address, "hookType", sleep=sleep) if node_type in (HOOK_ROUTING, HOOK_FALLBACK_ROUTING): diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 5ed5c078..e22fd3e9 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -244,15 +244,37 @@ def from_string(value: str) -> str: return value +ETH_DOMAIN, SOL_DOMAIN = "1u32", "1399811149u32" +ROUTER = "{ domain: 1u32, recipient: [0u8], gas: 1u128 }" + + +def _warp(pause_mapping: str, *domains: str) -> dict: + """A warp program as the pause gate reads it: its pause convention declared and every far router enrolled.""" + return {pause_mapping: {}, "remote_routers": {d: ROUTER for d in domains}, "app_metadata": {}} + + def default_mappings() -> dict: return { "hyp_hook_manager.aleo": {"destination_gas_configs": {IGP_KEY_ETH: ETH_GAS_CONFIG, IGP_KEY_SOL: SOL_GAS_CONFIG}}, "hyp_mailbox.aleo": {"deliveries": {DELIVERED_KEY: "{ block_height: 1u32 }"}}, - "usdcx_bridge_v2.aleo": {"nullifier": {"[" + ",".join(f"{b}u8" for b in NULLIFIED_NONCE) + "]": "true"}}, + "usdcx_bridge_v2.aleo": {"nullifier": {"[" + ",".join(f"{b}u8" for b in NULLIFIED_NONCE) + "]": "true"}, "paused": {}}, "credits.aleo": {"account": {SIGNER: "2392443u64"}}, - "usdcx_stablecoin.aleo": {"balances": {SIGNER: "1000000u128"}, "freeze_list": {}, "freeze_list_last_index": {}}, - "arc20_wbtc.aleo": {"balances": {SIGNER: "10000u128"}}, - "arc20_eth.aleo": {"balances": {}}, + "usdcx_stablecoin.aleo": {"balances": {SIGNER: "1000000u128"}, "freeze_list": {}, "freeze_list_last_index": {}, + "pause": {}}, + "arc20_wbtc.aleo": {"balances": {SIGNER: "10000u128"}, "pause__": {}}, + "arc20_eth.aleo": {"balances": {}, "pause__": {}}, + "arc20_usdt.aleo": {"pause__": {}}, "arc20_sol.aleo": {"pause__": {}}, + "shield_arc22_bat.aleo": {"pause__": {}}, "shield_arc22_usdg.aleo": {"pause__": {}}, "shield_arc22_zec.aleo": {"pause__": {}}, + # every pause switch open, every far router enrolled (mainnet conventions as of 2026-10-30) + "hyp_warp_token_eth_v2.aleo": _warp("pause", ETH_DOMAIN), + "hyp_warp_token_wbtc_v2.aleo": _warp("pause", ETH_DOMAIN), + "hyp_warp_token_usdt_v2.aleo": _warp("pause", ETH_DOMAIN), + "hyp_warp_token_sol_v2.aleo": _warp("pause", SOL_DOMAIN), + "hyp_warp_token_bat_v2.aleo": _warp("pause__", ETH_DOMAIN, SOL_DOMAIN), + "hyp_warp_token_usdg_v2.aleo": _warp("pause__", ETH_DOMAIN, SOL_DOMAIN), + "hyp_warp_token_zec_v2.aleo": _warp("pause__", SOL_DOMAIN), + # testnet xReserve programs declare the same conventions + "test_usdcx_bridge_v2.aleo": {"paused": {}}, "test_usdcx_stablecoin.aleo": {"pause": {}}, } diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index b50e0517..7b486c7c 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -26,7 +26,7 @@ from aleo_bridge.registry import DEFAULT_REGISTRY, Route from aleo_bridge.types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, GasQuote, - MintReceipt, PreparedTx, PrivacyReceipt, Receipt, + MintReceipt, PauseStatus, PauseSwitch, PreparedTx, PrivacyReceipt, Receipt, SolanaHyperlaneQuote, Status) from tests.conftest import FakeAleo as _ConftestFakeAleo @@ -487,6 +487,21 @@ def __init__(self, *, environment="mainnet", ethereum=True, solana=False, checkp self.solana = FakeConnection(SOL_ADDRESS) if solana else None self._sol = FakeSol(self, SOL_ADDRESS) if solana else None self.public_balances: dict[str, int] = {} + # Pause gate: route_id -> PauseStatus to answer with (default: one open switch per requested side). + # Checks are recorded in pause_checks, NOT in calls, so module-call assertions stay untouched. + self.pause_statuses: dict[str, PauseStatus] = {} + self.pause_checks: list[tuple[str, tuple[str, ...]]] = [] + + def pause_status(self, route, *, sides=("source", "destination")) -> PauseStatus: + """Mirrors ``Bridge.pause_status``: returns the stored status filtered to the requested sides.""" + route_id = getattr(route, "route_id", None) or getattr(route, "id", None) or route + self.pause_checks.append((route_id, tuple(sides))) + stored = self.pause_statuses.get(route_id) + if stored is None: + switches = tuple(PauseSwitch("aleo", side, "fake.aleo", "pause[true]", "open", "false") for side in sides) + else: + switches = tuple(s for s in stored.switches if s.side in sides) + return PauseStatus(route_id, switches) @property def eth(self) -> FakeEth: diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index ad0bd72e..406a63ba 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -28,6 +28,8 @@ keccak(text="quoteTransferRemote(uint32,bytes32,uint256)")[:4]: "quoteTransferRemote", keccak(text="delivered(bytes32)")[:4]: "delivered", } +# Hyperlane Mailbox hook / ISM tree getters the pause reader walks; unscripted, they answer the zero address. +_TREE_GETTERS = {keccak(text=sig)[:4] for sig in ("defaultHook()", "requiredHook()", "defaultIsm()", "hooks(uint32)", "module(uint32)")} TOPIC_DISPATCH_ID = "0x" + keccak(text="DispatchId(bytes32)").hex() TOPIC_SENT_TRANSFER_REMOTE = "0x" + keccak(text="SentTransferRemote(uint32,bytes32,uint256)").hex() TOPIC_DEPOSITED_TO_REMOTE = "0x" + keccak( @@ -342,6 +344,10 @@ def _call(self, call: dict) -> str: if name == "delivered": (message_id,) = decode(["bytes32"], args) return "0x" + encode(["bool"], [("0x" + message_id.hex()) in self.delivered]).hex() + if data[:4] == bytes.fromhex("5c975abb"): # paused(): every unscripted contract is unpaused + return "0x" + encode(["bool"], [False]).hex() + if data[:4] in _TREE_GETTERS: # Mailbox hook / ISM getters: nothing configured + return "0x" + encode(["address"], [ZERO_ADDRESS]).hex() raise NotImplementedError(f"eth_call selector {data[:4].hex()} to {to}") diff --git a/bridge-sdk/tests/test_pause_gate.py b/bridge-sdk/tests/test_pause_gate.py new file mode 100644 index 00000000..94e68a61 --- /dev/null +++ b/bridge-sdk/tests/test_pause_gate.py @@ -0,0 +1,98 @@ +"""quote / execute / resume / complete refuse a paused or unreadable route before anything is signed.""" +import pytest + +from aleo_bridge.errors import PauseCheckError, RoutePausedError, RouteUnavailableError +from aleo_bridge.lifecycle import complete, execute, quote, resume +from aleo_bridge.types import PauseStatus, PauseSwitch, to_progress +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge +from tests.test_resume_complete import _prepared_progress, _ready + +ALEO_ETH = "hyperlane:aleo/eth->ethereum/eth" +QUOTE = dict(source_chain="aleo", source_asset="eth", destination_chain="ethereum", destination_asset="eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS) + + +def paused(rid, side="source"): + return PauseStatus(rid, (PauseSwitch("aleo", side, "hyp_warp_token_eth_v2.aleo", "pause[true]", "paused", "true"),)) + + +def unknown(rid): + return PauseStatus(rid, (PauseSwitch("ethereum", "destination", "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", "paused()", + "unknown", "ConnectionError: down after 7 attempts"),)) + + +def test_quote_refuses_paused_before_any_module_call(): + b = FakeBridge(ethereum=False) + b.pause_statuses[ALEO_ETH] = paused(ALEO_ETH) + with pytest.raises(RoutePausedError, match=r"pause\[true\] on hyp_warp_token_eth_v2.aleo") as info: + quote(b, **QUOTE) + assert b.calls == [] and b.pause_checks == [(ALEO_ETH, ("source", "destination"))] + assert isinstance(info.value, RouteUnavailableError) and info.value.status.paused + + +def test_quote_and_execute_refuse_unknown_fail_closed(): + b = FakeBridge(ethereum=False) + plan = quote(b, **QUOTE).plan # open: quote works + b.pause_statuses[ALEO_ETH] = unknown(ALEO_ETH) + with pytest.raises(PauseCheckError, match="after 7 attempts"): + quote(b, **QUOTE) + with pytest.raises(PauseCheckError, match="never proceeds"): + execute(b, plan) + assert b.events == [] and all(c[0] != "hyperlane.transfer_remote" for c in b.calls) + + +def test_execute_checks_both_sides_after_require_active(): + b = FakeBridge(ethereum=False) + plan = quote(b, **QUOTE).plan + b.pause_checks.clear() + b.pause_statuses[ALEO_ETH] = paused(ALEO_ETH, side="destination") + with pytest.raises(RoutePausedError, match="destination side"): + execute(b, plan) + assert b.events == [] and b.pause_checks == [(ALEO_ETH, ("source", "destination"))] + # a metadata-required route is refused by availability first; no pause read happens for it + b.pause_checks.clear() + with pytest.raises(RouteUnavailableError, match="metadata-required"): + quote(b, route="hyperlane:aleo/aleo->ethereum/aleo", amount="1", recipient=EVM_ADDRESS) + assert b.pause_checks == [] + + +def test_resume_checks_both_sides_and_refuses_paused(): + b = FakeBridge(ethereum=False) + plan, _serialized, progress = _prepared_progress(b) + b.pause_statuses[plan.route_id] = paused(plan.route_id) + with pytest.raises(RoutePausedError): + resume(b, progress) + assert b.submitted == [] and b.pause_checks == [(plan.route_id, ("source", "destination"))] + + +def test_complete_checks_destination_only(): + b = FakeBridge(environment="testnet") + plan, _payload, _hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + complete(b, to_progress(plan, ready), secret_nonce="7scalar") + assert b.pause_checks[-1] == (plan.route_id, ("destination",)) + + +def test_complete_ignores_source_side_switches(): + b = FakeBridge(environment="testnet") + plan, _payload, _hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + rid = plan.route_id + b.pause_statuses[rid] = PauseStatus(rid, ( + PauseSwitch("sepolia", "source", "0x008888878f94C0d87defdf0B07f46B93C1934442", "paused()", "paused", "true"), + PauseSwitch("aleo-testnet", "destination", "test_usdcx_bridge_v2.aleo", "paused[true]", "open", "false"))) + out = complete(b, to_progress(plan, ready), secret_nonce="7scalar") # only the requested side is returned + assert out.next == "wait" and len(b.submitted) == 1 + b.pause_statuses[rid] = PauseStatus(rid, ( + PauseSwitch("aleo-testnet", "destination", "test_usdcx_bridge_v2.aleo", "paused[true]", "paused", "true"),)) + with pytest.raises(RoutePausedError, match="test_usdcx_bridge_v2.aleo"): + complete(b, to_progress(plan, ready), secret_nonce="7scalar") + assert len(b.submitted) == 1 + + +def test_gate_runs_once_per_verb(): + b = FakeBridge(ethereum=False) + plan = quote(b, **QUOTE).plan + assert len(b.pause_checks) == 1 + execute(b, plan) + assert len(b.pause_checks) == 2 From 28826a1801b67b7e36e3cc178addc94b9eebe76c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:21:41 -0400 Subject: [PATCH 11/13] feat(bridge): bridge_pause_status agent tool and pause-aware error fixes --- bridge-sdk/python/aleo_bridge/agent.py | 27 +- .../fixtures/agent-tools-2026-10-30.json | 409 ++++++++++++++++++ bridge-sdk/tests/test_agent.py | 36 +- 3 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/tests/fixtures/agent-tools-2026-10-30.json diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index 99d39f2f..01c8bc3a 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -38,7 +38,7 @@ from . import lifecycle from .checkpoint import Checkpoint, create_checkpoint -from .errors import BridgeError, ConfigurationError +from .errors import BridgeError, ConfigurationError, PauseCheckError, RoutePausedError from .registry import DEFAULT_REGISTRY, Registry from .types import EvmXReserveQuote, Receipt from .units import format_decimal_amount @@ -56,6 +56,10 @@ "never stored it, and no other value can open the commitment") RECOVER_HOW_TO_FIX = ("the source step may already be on the wire: call bridge_get_progress with the " "checkpoint (or bridge_pending) before doing anything else — never bridge_execute again") +PAUSED_HOW_TO_FIX = ("the operator has paused this route on chain; wait for it to be unpaused and re-check with " + "bridge_pause_status — no change of input will make it go through") +PAUSE_CHECK_HOW_TO_FIX = ("a pause switch could not be read after retries; retry later, or point the client at " + "another RPC endpoint (ETHEREUM_RPC_URL / _RPC_URL, profile.set_rpc_url, or evm=)") #: Receipt ``protocol_state`` keys that are dropped from every rendered receipt: the private-mint #: secret and record plaintext (secret), the Circle attestation body (secret-adjacent and large), @@ -155,6 +159,10 @@ def _redacted_state(state: Any) -> Any: # ── errors ──────────────────────────────────────────────────────────────────── def _how_to_fix(exc: Exception) -> str | None: + if isinstance(exc, RoutePausedError): + return PAUSED_HOW_TO_FIX + if isinstance(exc, PauseCheckError): + return PAUSE_CHECK_HOW_TO_FIX message = str(exc).lower() if "secret_nonce" in message or "secret nonce" in message: return NONCE_HOW_TO_FIX @@ -307,6 +315,15 @@ def _h_list_routes(b, a): environment=a.get("environment", b.environment)), b.registry) +def _h_pause_status(b, a): + route = lifecycle.select_route(b.registry, source_chain=a.get("source_chain"), source_asset=a.get("source_asset"), + destination_chain=a.get("destination_chain"), destination_asset=a.get("destination_asset"), + bridge_protocol=a.get("bridge_protocol"), route=a.get("route")) + status = b.pause_status(route) + return {"route_id": status.route_id, "paused": status.paused, "unknown": status.unknown, "reason": status.reason, + "switches": [dataclasses.asdict(s) for s in status.switches]} + + def _h_quote(b, a): gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source_chain")) if gap is not None: @@ -455,6 +472,14 @@ def _h_unshield(b, a): _schema({"source_chain": _S, "source_asset": _S, "destination_chain": _S, "destination_asset": _S, "bridge_protocol": {**_S, "enum": ["xreserve", "hyperlane"]}, "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 " + "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_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/tests/fixtures/agent-tools-2026-10-30.json b/bridge-sdk/tests/fixtures/agent-tools-2026-10-30.json new file mode 100644 index 00000000..5c6ce5bc --- /dev/null +++ b/bridge-sdk/tests/fixtures/agent-tools-2026-10-30.json @@ -0,0 +1,409 @@ +[ + { + "description": "Re-orient: environment, registry version, the configured Aleo/Ethereum/Solana addresses with balances of every bridge asset (atomic units), and pending (in-flight) transfers. Run this FIRST in any session.", + "input_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "bridge_status" + }, + { + "description": "Assets that can be bridged, with chain, symbol, decimals and on-chain locator. Filter by chain id (aleo, ethereum, solana, aleo-testnet, sepolia) or symbol.", + "input_schema": { + "properties": { + "chain": { + "type": "string" + }, + "environment": { + "enum": [ + "mainnet", + "testnet" + ], + "type": "string" + }, + "symbol": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "bridge_list_assets" + }, + { + "description": "Supported directions and their protocol (xreserve = USDC<->USDCx via Circle; hyperlane = ETH/WBTC/USDT/SOL/BAT/USDG/ZEC). Active routes move funds; metadata-required ones are listed but refused by quote/execute.", + "input_schema": { + "properties": { + "bridge_protocol": { + "enum": [ + "xreserve", + "hyperlane" + ], + "type": "string" + }, + "destination_asset": { + "type": "string" + }, + "destination_chain": { + "type": "string" + }, + "environment": { + "enum": [ + "mainnet", + "testnet" + ], + "type": "string" + }, + "include_unavailable": { + "type": "boolean" + }, + "source_asset": { + "type": "string" + }, + "source_chain": { + "type": "string" + }, + "symbol": { + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "bridge_list_routes" + }, + { + "description": "Validate and price a transfer: route, fees (human units), amount_out, approval needs. Reads chain state, never signs. ALWAYS quote before bridge_execute and show the user fees and amount_out. A private mint (mint_mode='private') must carry the user's own secret_nonce \u2014 there is no default.", + "input_schema": { + "properties": { + "amount": { + "description": "Positive decimal amount in source-asset display units (e.g. '2', '0.001').", + "type": "string" + }, + "bridge_protocol": { + "description": "Only needed when multiple protocols serve the pair.", + "enum": [ + "xreserve", + "hyperlane", + "cctp" + ], + "type": "string" + }, + "cctp": { + "additionalProperties": false, + "properties": { + "forwarding": { + "type": "boolean" + }, + "max_fee": { + "description": "Maximum USDC deduction, preserved through recovery and fee refresh.", + "type": "string" + }, + "speed": { + "enum": [ + "standard", + "fast" + ], + "type": "string" + } + }, + "type": "object" + }, + "destination_asset": { + "description": "Asset key on the destination chain ('usdcx', 'eth', ...). Only needed when the source asset can arrive as more than one asset.", + "type": "string" + }, + "destination_chain": { + "description": "Chain the funds arrive on: 'aleo', 'ethereum', 'solana'.", + "type": "string" + }, + "mint_mode": { + "description": "xReserve into Aleo only. 'private' requires the user to complete the mint later with the same secret_nonce.", + "enum": [ + "public", + "record", + "private" + ], + "type": "string" + }, + "recipient": { + "description": "Destination-chain address that receives the funds.", + "type": "string" + }, + "secret_nonce": { + "description": "Private-mint commitment secret, REQUIRED when mint_mode is 'private' (there is no default). The user must keep it for bridge_complete; the SDK never stores it.", + "type": "string" + }, + "sender": { + "description": "Optional source-chain address; must be the configured connection's address.", + "type": "string" + }, + "source_asset": { + "description": "Asset key on the source chain: 'usdc', 'eth', 'wbtc', 'usdt', 'sol', 'usdcx'.", + "type": "string" + }, + "source_chain": { + "description": "Chain the funds leave: 'ethereum', 'solana', 'aleo' (testnet: 'sepolia', 'aleo-testnet').", + "type": "string" + } + }, + "required": [ + "source_chain", + "source_asset", + "destination_chain", + "amount", + "recipient" + ], + "type": "object" + }, + "name": "bridge_quote" + }, + { + "description": "Recover a transfer's state from a checkpoint (reads only). Returns {progress, checkpoint}: progress.next tells what to do \u2014 wait (call again later), resume (bridge_resume), complete (bridge_complete), done, failed \u2014 and the checkpoint is the fresh one to pass to whichever of those you call.", + "input_schema": { + "properties": { + "approval_replacement": { + "additionalProperties": false, + "properties": { + "original_transaction_id": { + "type": "string" + }, + "replacement_transaction_id": { + "type": "string" + } + }, + "required": [ + "original_transaction_id", + "replacement_transaction_id" + ], + "type": "object" + }, + "checkpoint": { + "description": "The checkpoint dict returned by bridge_get_progress, by an entry of bridge_pending, or by bridge_execute / bridge_resume / bridge_complete (including the one an interrupted write hands back with next='recover').", + "type": "object" + } + }, + "required": [ + "checkpoint" + ], + "type": "object" + }, + "name": "bridge_get_progress" + }, + { + "description": "Every in-flight transfer in this profile's checkpoint store, one {progress, checkpoint} entry each, reconstructed offline (no chain read, so its progress can lag: bridge_get_progress refreshes one against live state). A record too damaged to interpret comes back as {error, checkpoint_id} in its place.", + "input_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "bridge_pending" + }, + { + "description": "Start a transfer: re-quotes the same inputs, then commits funds on the source chain. Requires confirm=true; without it returns the quote and moves nothing. The source step is IRREVERSIBLE once broadcast \u2014 afterwards use bridge_get_progress with the returned checkpoint, never bridge_execute again. For mint_mode=private the user must supply secret_nonce here (no default) and keep it for bridge_complete.", + "input_schema": { + "properties": { + "amount": { + "description": "Positive decimal amount in source-asset display units (e.g. '2', '0.001').", + "type": "string" + }, + "bridge_protocol": { + "description": "Only needed when multiple protocols serve the pair.", + "enum": [ + "xreserve", + "hyperlane", + "cctp" + ], + "type": "string" + }, + "cctp": { + "additionalProperties": false, + "properties": { + "forwarding": { + "type": "boolean" + }, + "max_fee": { + "description": "Maximum USDC deduction, preserved through recovery and fee refresh.", + "type": "string" + }, + "speed": { + "enum": [ + "standard", + "fast" + ], + "type": "string" + } + }, + "type": "object" + }, + "confirm": { + "description": "Set true to move funds. Without it the quote is returned and nothing is submitted.", + "type": "boolean" + }, + "destination_asset": { + "description": "Asset key on the destination chain ('usdcx', 'eth', ...). Only needed when the source asset can arrive as more than one asset.", + "type": "string" + }, + "destination_chain": { + "description": "Chain the funds arrive on: 'aleo', 'ethereum', 'solana'.", + "type": "string" + }, + "gas_payment_microcredits": { + "type": "integer" + }, + "mint_mode": { + "description": "xReserve into Aleo only. 'private' requires the user to complete the mint later with the same secret_nonce.", + "enum": [ + "public", + "record", + "private" + ], + "type": "string" + }, + "mode": { + "description": "Aleo-origin only: caller|signer (Hyperlane) or private|public|public-as-signer (xReserve burn).", + "type": "string" + }, + "proving": { + "enum": [ + "delegate", + "local" + ], + "type": "string" + }, + "recipient": { + "description": "Destination-chain address that receives the funds.", + "type": "string" + }, + "secret_nonce": { + "description": "Private-mint commitment secret, REQUIRED when mint_mode is 'private' (there is no default). The user must keep it for bridge_complete; the SDK never stores it.", + "type": "string" + }, + "sender": { + "description": "Optional source-chain address; must be the configured connection's address.", + "type": "string" + }, + "source_asset": { + "description": "Asset key on the source chain: 'usdc', 'eth', 'wbtc', 'usdt', 'sol', 'usdcx'.", + "type": "string" + }, + "source_chain": { + "description": "Chain the funds leave: 'ethereum', 'solana', 'aleo' (testnet: 'sepolia', 'aleo-testnet').", + "type": "string" + } + }, + "required": [ + "source_chain", + "source_asset", + "destination_chain", + "amount", + "recipient" + ], + "type": "object" + }, + "name": "bridge_execute" + }, + { + "description": "Finish an interrupted source submission (progress.next == 'resume'): rebroadcasts the identical proved Aleo transaction or authorizes the single missing EVM step after re-scanning history. Requires confirm=true. An interrupted EVM xReserve deposit needs the same secret_nonce used at execute.", + "input_schema": { + "properties": { + "checkpoint": { + "description": "The checkpoint dict returned by bridge_get_progress, by an entry of bridge_pending, or by bridge_execute / bridge_resume / bridge_complete (including the one an interrupted write hands back with next='recover').", + "type": "object" + }, + "confirm": { + "description": "Set true to move funds. Without it the quote is returned and nothing is submitted.", + "type": "boolean" + }, + "secret_nonce": { + "description": "The value used at bridge_execute; required to resume a private-mint xReserve deposit.", + "type": "string" + } + }, + "required": [ + "checkpoint" + ], + "type": "object" + }, + "name": "bridge_resume" + }, + { + "description": "Complete a private USDCx or CCTP mint. Private USDCx requires the original secret_nonce. CCTP forwarding fallback requires manual_mint=true. Requires confirm=true.", + "input_schema": { + "properties": { + "checkpoint": { + "description": "The checkpoint dict returned by bridge_get_progress, by an entry of bridge_pending, or by bridge_execute / bridge_resume / bridge_complete (including the one an interrupted write hands back with next='recover').", + "type": "object" + }, + "confirm": { + "description": "Set true to move funds. Without it the quote is returned and nothing is submitted.", + "type": "boolean" + }, + "manual_mint": { + "description": "Explicitly mint a stalled CCTP forwarded transfer.", + "type": "boolean" + }, + "secret_nonce": { + "description": "The private-mint secret used at bridge_execute \u2014 required (there is no default); only an already-proved mint can be rebroadcast without it.", + "type": "string" + } + }, + "required": [ + "checkpoint" + ], + "type": "object" + }, + "name": "bridge_complete" + }, + { + "description": "Move a public Aleo balance of a bridged asset (aleo/eth, aleo/wbtc, aleo/usdt, aleo/sol, aleo/usdcx) into a private record. Requires confirm=true; without it returns the program call for review.", + "input_schema": { + "properties": { + "amount": { + "type": "string" + }, + "amount_atomic": { + "type": "integer" + }, + "asset": { + "type": "string" + }, + "confirm": { + "description": "Set true to move funds. Without it the quote is returned and nothing is submitted.", + "type": "boolean" + } + }, + "required": [ + "asset" + ], + "type": "object" + }, + "name": "bridge_shield" + }, + { + "description": "Move a private record of a bridged asset back to the public balance (needed before an Aleo-origin Hyperlane transfer). Requires confirm=true.", + "input_schema": { + "properties": { + "amount": { + "type": "string" + }, + "amount_atomic": { + "type": "integer" + }, + "asset": { + "type": "string" + }, + "confirm": { + "description": "Set true to move funds. Without it the quote is returned and nothing is submitted.", + "type": "boolean" + } + }, + "required": [ + "asset" + ], + "type": "object" + }, + "name": "bridge_unshield" + } +] \ No newline at end of file diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py index c025b1d6..196f1dd0 100644 --- a/bridge-sdk/tests/test_agent.py +++ b/bridge-sdk/tests/test_agent.py @@ -18,7 +18,7 @@ from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, RECORD_PLAINTEXT, FakeBridge READS = {"bridge_status", "bridge_list_assets", "bridge_list_routes", "bridge_quote", "bridge_get_progress", - "bridge_pending"} + "bridge_pending", "bridge_pause_status"} WRITES = {"bridge_execute", "bridge_resume", "bridge_complete", "bridge_shield", "bridge_unshield"} QUOTE_ARGS = {"source_chain": "ethereum", "source_asset": "usdc", "destination_chain": "aleo", "destination_asset": "usdcx", "amount": "2", "recipient": ALEO_RECIPIENT} @@ -508,3 +508,37 @@ def test_complete_write_error_surfaces_recover_guidance_and_the_prepared_checkpo # the pre-broadcast checkpoint made it out: the proved mint can be rebroadcast, never re-proved assert out["checkpoint"]["destination"]["preparedTransaction"]["transactionId"] == "at1fake1" assert NONCE not in json.dumps(out) + + +# ── pause status ────────────────────────────────────────────────────────────── + +def test_pause_status_tool_reports_switches_and_write_errors_name_the_fix(): + from aleo_bridge.agent import PAUSED_HOW_TO_FIX, PAUSE_CHECK_HOW_TO_FIX + from aleo_bridge.types import PauseStatus, PauseSwitch + b = FakeBridge(ethereum=False) + rid = "hyperlane:aleo/eth->ethereum/eth" + out = dispatch_tool(b, "bridge_pause_status", {"route": rid}) + assert out["route_id"] == rid and out["paused"] is False and out["unknown"] is False + assert out["switches"][0]["state"] == "open" and "open" in out["reason"] + by_selectors = dispatch_tool(b, "bridge_pause_status", {"source_chain": "aleo", "source_asset": "eth", + "destination_chain": "ethereum"}) + assert by_selectors["route_id"] == rid + b.pause_statuses[rid] = PauseStatus(rid, (PauseSwitch("aleo", "source", "hyp_warp_token_eth_v2.aleo", "pause[true]", + "paused", "true"),)) + args = {"source_chain": "aleo", "source_asset": "eth", "destination_chain": "ethereum", "destination_asset": "eth", + "amount": "0.000000000000000001", "recipient": EVM_ADDRESS, "confirm": True} + out = dispatch_tool(b, "bridge_execute", args) + assert out["error_type"] == "RoutePausedError" and out["how_to_fix"] == PAUSED_HOW_TO_FIX and b.events == [] + b.pause_statuses[rid] = PauseStatus(rid, (PauseSwitch("ethereum", "destination", "0x" + "88" * 20, "paused()", + "unknown", "ConnectionError: down after 7 attempts"),)) + out = dispatch_tool(b, "bridge_execute", args) + assert out["error_type"] == "PauseCheckError" and out["how_to_fix"] == PAUSE_CHECK_HOW_TO_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(): + snap = {t["name"]: t for t in json.loads((Path(__file__).parent / "fixtures" / "agent-tools-2026-10-30.json").read_text())} + now = {t["name"]: t for t in bridge_tools()} + assert set(now) - set(snap) == {"bridge_pause_status"} + for name, tool in snap.items(): + assert now[name] == tool, name From ebc999c4819dacc96af0bcb528d42a523bd66b78 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:24:46 -0400 Subject: [PATCH 12/13] docs(bridge): document pause checks; live pause read test --- bridge-sdk/AGENTS.md | 29 ++++++++++++++--- bridge-sdk/README.md | 26 ++++++++++++++++ bridge-sdk/codegen/gen_context.py | 2 +- bridge-sdk/python/aleo_bridge/AGENTS.md | 29 ++++++++++++++--- bridge-sdk/python/aleo_bridge/client.py | 16 +++++++--- bridge-sdk/tests/live/test_pause_reads.py | 38 +++++++++++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 bridge-sdk/tests/live/test_pause_reads.py diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index 0d84a32f..5ba60537 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -78,7 +78,9 @@ yourself with ``complete`` and must keep ``secret_nonce``. Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the -user fees + amount before ``execute``. +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``). ### `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)` @@ -98,8 +100,10 @@ record and computes the freeze-list exclusion proof, caching it until the list changes; ``merkle_proof`` is the earlier name for one literal bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is -pinned. Irreversible once the source step is broadcast: afterwards use -``wait`` / ``recover``, never ``execute`` again. +pinned. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a +pause switch on either end reads paused or cannot be read. Irreversible +once the source step is broadcast: afterwards use ``wait`` / ``recover``, +never ``execute`` again. ### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` @@ -128,7 +132,9 @@ Finish an interrupted source submission (``progress.next == "resume"``). Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single -missing deposit/dispatch. Never repeats a confirmed step. +missing deposit/dispatch. Never repeats a confirmed step. Refuses with +``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either +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)` @@ -140,6 +146,8 @@ stored it. Submits exactly one ``private_mint`` and returns nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already submitted destination transaction is observed rather than repeated. +Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch +on the destination side reads paused or cannot be read. ### `pending(self) -> 'list'` @@ -155,6 +163,19 @@ could not even read back come back as ``{"next": "failed", "error", "error_type" (naming the ``checkpoint_id`` or the ``path``) alongside the healthy ``Progress`` objects, so a stale or corrupt file can never make a transfer that is still on the wire invisible. +### `pause_status(self, route, *, sides=('source', 'destination')) -> 'PauseStatus'` + +Read every operator pause switch governing ``route`` (a ``Route``, its id, or a ``Plan``), live. + +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 +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 +configured connection is read over the profile's ``rpc_urls`` or the registry's public endpoints. + ## Serving a chatting user (the conversation pattern) ### Keys and identity diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 650d4ecc..95460e0d 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -176,6 +176,32 @@ Keep `quote.plan` unchanged when accepting the quote so the submitted transfer uses the reviewed asset, amount, and recipient. Request a new quote to change those details. +### Paused routes + +Every bridge deployment has an operator switch that can halt it: the `pause` +mapping on the Aleo warp and USDCx programs, `paused()` on Circle's xReserve +and CCTP transmitters, a pausable hook inside the Ethereum Hyperlane Mailbox, +and the enrolled-router table of the Solana warp program. `quote`, `execute`, +`resume` and `complete` read every switch that governs the route, on both +ends, right before they act, and refuse with `RoutePausedError` naming the +switch when one is set. A switch that cannot be read after retries refuses +with `PauseCheckError`; the SDK never proceeds on a guess. + +Ask before quoting with `bridge.pause_status(route)`, which returns each +switch and its value: + +```python +status = bridge.pause_status("hyperlane:aleo/eth->ethereum/eth") +print(status.paused, status.reason) +for switch in status.switches: + print(switch.chain_id, switch.side, switch.locator, switch.switch, switch.state) +``` + +Reading the far side of an Aleo-origin route needs an RPC for that chain: +the one you configured, one saved with `profile.set_rpc_url("ethereum", url)`, +or the public read endpoint the registry pins. Those public endpoints are used +for pause reads only, never for signing. + ### 2. Submit a bridge transaction from the source chain The source transaction deposits the sender's funds into the bridge so they diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py index f0d7a097..5ddfbb7e 100644 --- a/bridge-sdk/codegen/gen_context.py +++ b/bridge-sdk/codegen/gen_context.py @@ -28,7 +28,7 @@ OUTS = [_ROOT / "AGENTS.md", _ROOT / "python" / "aleo_bridge" / "AGENTS.md"] TIER1 = ["from_env", "from_profile", "status", "quote", "execute", "wait", "recover", "resume", - "complete", "pending"] + "complete", "pending", "pause_status"] TIER2 = [ ("hyperlane.transfer_remote", HyperlaneModule.transfer_remote), ("hyperlane.quote_gas_payment", HyperlaneModule.quote_gas_payment), diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index 0d84a32f..5ba60537 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -78,7 +78,9 @@ yourself with ``complete`` and must keep ``secret_nonce``. Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the -user fees + amount before ``execute``. +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``). ### `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)` @@ -98,8 +100,10 @@ record and computes the freeze-list exclusion proof, caching it until the list changes; ``merkle_proof`` is the earlier name for one literal bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is -pinned. Irreversible once the source step is broadcast: afterwards use -``wait`` / ``recover``, never ``execute`` again. +pinned. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a +pause switch on either end reads paused or cannot be read. Irreversible +once the source step is broadcast: afterwards use ``wait`` / ``recover``, +never ``execute`` again. ### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` @@ -128,7 +132,9 @@ Finish an interrupted source submission (``progress.next == "resume"``). Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single -missing deposit/dispatch. Never repeats a confirmed step. +missing deposit/dispatch. Never repeats a confirmed step. Refuses with +``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either +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)` @@ -140,6 +146,8 @@ stored it. Submits exactly one ``private_mint`` and returns nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already submitted destination transaction is observed rather than repeated. +Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch +on the destination side reads paused or cannot be read. ### `pending(self) -> 'list'` @@ -155,6 +163,19 @@ could not even read back come back as ``{"next": "failed", "error", "error_type" (naming the ``checkpoint_id`` or the ``path``) alongside the healthy ``Progress`` objects, so a stale or corrupt file can never make a transfer that is still on the wire invisible. +### `pause_status(self, route, *, sides=('source', 'destination')) -> 'PauseStatus'` + +Read every operator pause switch governing ``route`` (a ``Route``, its id, or a ``Plan``), live. + +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 +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 +configured connection is read over the profile's ``rpc_urls`` or the registry's public endpoints. + ## Serving a chatting user (the conversation pattern) ### Keys and identity diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index aac4378d..8376ae4e 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -384,7 +384,9 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the - user fees + amount before ``execute``. + 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``). """ return _lifecycle.quote(self, source_chain=source_chain, source_asset=source_asset, destination_chain=destination_chain, destination_asset=destination_asset, @@ -413,8 +415,10 @@ def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: the list changes; ``merkle_proof`` is the earlier name for one literal bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` is - pinned. Irreversible once the source step is broadcast: afterwards use - ``wait`` / ``recover``, never ``execute`` again. + pinned. Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a + pause switch on either end reads paused or cannot be read. Irreversible + once the source step is broadcast: afterwards use ``wait`` / ``recover``, + never ``execute`` again. """ return _lifecycle.execute(self, plan, on_checkpoint=on_checkpoint, proving=proving, mode=mode, record=record, compliance_proofs=compliance_proofs, merkle_proof=merkle_proof, @@ -459,7 +463,9 @@ def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = Non Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single - missing deposit/dispatch. Never repeats a confirmed step. + missing deposit/dispatch. Never repeats a confirmed step. Refuses with + ``RoutePausedError`` / ``PauseCheckError`` when a pause switch on either + end reads paused or cannot be read. """ return _lifecycle.resume(self, progress, on_checkpoint=on_checkpoint, secret_nonce=secret_nonce, poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, proving=proving) @@ -474,6 +480,8 @@ def complete(self, progress, *, secret_nonce: str | None = None, on_checkpoint=N nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already submitted destination transaction is observed rather than repeated. + Refuses with ``RoutePausedError`` / ``PauseCheckError`` when a pause switch + on the destination side reads paused or cannot be read. """ options = {"manual_mint": manual_mint} if manual_mint else {} return _lifecycle.complete(self, progress, secret_nonce=secret_nonce, on_checkpoint=on_checkpoint, diff --git a/bridge-sdk/tests/live/test_pause_reads.py b/bridge-sdk/tests/live/test_pause_reads.py new file mode 100644 index 00000000..52901e14 --- /dev/null +++ b/bridge-sdk/tests/live/test_pause_reads.py @@ -0,0 +1,38 @@ +"""Read-only mainnet check: every pause switch on every active route resolves. + +Gated by BRIDGE_LIVE_READS=1. Keyless facade; no Ethereum or Solana connection is configured, so the +registry's public read endpoints and the default Solana RPC are the ones exercised. Which routes are +paused is printed, not asserted: operators flip those switches (on 2026-10-30 every Hyperlane warp +program except USDT was paused). A transient public-RPC failure after the retry budget is a skip, not +a failure in this SDK. +""" +import os +import re + +import pytest + +from aleo_bridge import Bridge +from aleo_bridge.registry import DEFAULT_REGISTRY + +pytestmark = [pytest.mark.live, pytest.mark.slow, + pytest.mark.skipif(os.environ.get("BRIDGE_LIVE_READS") != "1", reason="set BRIDGE_LIVE_READS=1")] + +TRANSIENT = re.compile(r"HTTP status (429|5\d\d)|ConnectionError|Timeout|request failed") +ACTIVE_MAINNET = [r.id for r in DEFAULT_REGISTRY.routes(environment="mainnet") if r.active] + + +@pytest.fixture(scope="module") +def keyless_bridge() -> Bridge: + from aleo import Aleo, HTTPProvider + return Bridge(Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")), ethereum=None, solana=None) + + +@pytest.mark.parametrize("route_id", ACTIVE_MAINNET) +def test_every_switch_on_every_active_route_resolves(keyless_bridge, route_id): + status = keyless_bridge.pause_status(route_id) + for s in status.switches: + if s.state == "unknown" and TRANSIENT.search(s.detail or ""): + pytest.skip(f"public RPC unavailable: {s.detail}") + assert status.switches, "a route with no switches would never be gated" + assert not status.unknown, status.reason + print(route_id, "PAUSED" if status.paused else "open", status.reason) From d0e972d5e3472ca33762b0528a46dd546f19dd32 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 30 Oct 2026 14:37:00 -0400 Subject: [PATCH 13/13] fix(bridge): pause reads fail closed on an unserved program, validate profile RPC chain ids, cap public RPC timeout --- bridge-sdk/python/aleo_bridge/pause.py | 43 ++++++++++++++---------- bridge-sdk/python/aleo_bridge/profile.py | 18 ++++++++-- bridge-sdk/tests/test_pause_aleo.py | 2 +- bridge-sdk/tests/test_pause_status.py | 22 +++++++++--- bridge-sdk/tests/test_profile.py | 16 +++++++++ 5 files changed, 76 insertions(+), 25 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/pause.py b/bridge-sdk/python/aleo_bridge/pause.py index 4f95fdae..deb1eaca 100644 --- a/bridge-sdk/python/aleo_bridge/pause.py +++ b/bridge-sdk/python/aleo_bridge/pause.py @@ -20,7 +20,6 @@ from typing import Any, Callable, Sequence, TypeVar from .errors import ConfigurationError -from .eth import Ethereum from .registry import Asset, Chain, Route from .sol import DEFAULT_SOLANA_RPC_URL, Solana from .types import PauseStatus, PauseSwitch @@ -72,17 +71,26 @@ def _aleo_mapping_value(bridge: Any, program: str, mapping: str, key: str, sleep program that does not exist, so a typo in a pinned mapping name would read as "open" forever. Checking the declared list first turns that into a ``ConfigurationError``. """ + if mapping not in _declared_mappings(bridge, program, sleep): + raise ConfigurationError( + f"{program} does not declare mapping {mapping!r}; the registry pins a program this node does not serve") + return read_with_retry(lambda _attempt: bridge.mapping_value(program, mapping, key), sleep=sleep) + + +def _declared_mappings(bridge: Any, program: str, sleep: Callable[[float], None]) -> list[str]: + """The mapping names *program* declares; a program the node does not serve is a ``ConfigurationError`` + (never "no switches"), so a registry typo can never make a switch vanish.""" from aleo.facade.errors import ProgramNotFound - def declared(_attempt: int) -> list[str]: + def declared(_attempt: int) -> list[str] | None: try: return list(bridge.program(program).mappings()) except ProgramNotFound: - return [] - if mapping not in read_with_retry(declared, sleep=sleep): - raise ConfigurationError( - f"{program} does not declare mapping {mapping!r}; the registry pins a program this node does not serve") - return read_with_retry(lambda _attempt: bridge.mapping_value(program, mapping, key), sleep=sleep) + return None + names = read_with_retry(declared, sleep=sleep) + if names is None: + raise ConfigurationError(f"{program} is not served by this node; the registry pins a program that is not deployed here") + return names PAUSE_MAPPINGS = (("pause", "true"), ("paused", "true"), ("pause__", "false")) # (mapping, key) conventions seen on chain @@ -91,15 +99,8 @@ def declared(_attempt: int) -> list[str]: def declared_pause_mapping(bridge: Any, program: str, *, sleep: Callable[[float], None] = time.sleep) -> tuple[str, str] | None: """Which pause convention *program* declares: ``pause[true]`` (ETH/WBTC/SOL warp, usdcx_stablecoin), ``paused[true]`` (usdcx_bridge_v2) or the ARC-20 ``pause__[false]`` (BAT/USDG/ZEC warp, arc20_*/shield_arc22_* - tokens); None when it has no pause switch at all.""" - from aleo.facade.errors import ProgramNotFound - - def declared(_attempt: int) -> list[str]: - try: - return list(bridge.program(program).mappings()) - except ProgramNotFound: - return [] - names = read_with_retry(declared, sleep=sleep) + tokens); None when the (deployed) program has no pause switch at all.""" + names = _declared_mappings(bridge, program, sleep) return next(((m, k) for m, k in PAUSE_MAPPINGS if m in names), None) @@ -250,6 +251,14 @@ def read_solana_remote_router(solana: Any, *, chain_id: str, side: str, token_pd # ── Per-route assembly ─────────────────────────────────────────────────────── SIDES = ("source", "destination") +PUBLIC_RPC_TIMEOUT_SECONDS = 10 # a hung public endpoint must not strand a quote for web3's 30 s default per call + + +def _public_web3(url: str) -> Any: + """A throwaway read-only ``Web3`` over a registry public endpoint, with a short request timeout.""" + from .eth import _web3 + web3 = _web3() + return web3.Web3(web3.HTTPProvider(url, request_kwargs={"timeout": PUBLIC_RPC_TIMEOUT_SECONDS})) class PauseReader: @@ -272,7 +281,7 @@ def evm_w3(self, chain_id: str) -> Any: raise ConfigurationError( f"No RPC connection for {chain_id} to read its pause switches: pass ethereum=/evm= to Bridge(...), " f"save one with profile.set_rpc_url({chain_id!r}, url), or set {chain_id.upper()}_RPC_URL") - return lambda attempt: Ethereum(rotate(urls, attempt)).w3 + return lambda attempt: _public_web3(rotate(urls, attempt)) def _solana(self) -> Any: return self._bridge.solana if self._bridge.solana is not None else Solana(DEFAULT_SOLANA_RPC_URL) diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py index 7bc989b4..1c05fd8c 100644 --- a/bridge-sdk/python/aleo_bridge/profile.py +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -56,7 +56,7 @@ def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mai if network not in NETWORKS: raise ConfigurationError(f"Profile network must be one of {NETWORKS}, got {network!r}") for chain_id, url in (rpc_urls or {}).items(): - _check_rpc_url(chain_id, url) + _check_rpc_url(chain_id, url, network) home_path = Path(home).expanduser() if home is not None else cls.default_home() path = home_path / _PROFILE if path.exists(): @@ -116,7 +116,7 @@ def rpc_urls(self) -> dict[str, str]: def set_rpc_url(self, chain_id: str, url: str) -> None: """Persist *url* for *chain_id* (explicit ``ethereum=``/``evm=`` arguments and env variables still win).""" - _check_rpc_url(chain_id, url) + _check_rpc_url(chain_id, url, self.network) self._data.setdefault("rpc_urls", {})[chain_id] = url path = self.home / _PROFILE tmp = path.with_suffix(".json.tmp") @@ -127,7 +127,19 @@ def set_rpc_url(self, chain_id: str, url: str) -> None: path.chmod(0o600) -def _check_rpc_url(chain_id: str, url: str) -> None: +def _check_rpc_url(chain_id: str, url: str, network: str) -> None: + """Refuse what ``Bridge.from_profile`` could not load: an unknown, non-EVM or other-environment chain.""" + from .registry import DEFAULT_REGISTRY + from .errors import RouteNotFoundError + try: + chain = DEFAULT_REGISTRY.chain(chain_id) + except RouteNotFoundError as exc: + raise ConfigurationError(f"RPC URL for unknown chain {chain_id!r}; registry chains: " + f"{[c.id for c in DEFAULT_REGISTRY.chains() if c.family == 'evm']}") from exc + if chain.family != "evm": + raise ConfigurationError(f"{chain.id} is not an EVM chain; the profile stores RPC URLs for EVM chains only") + if chain.environment != network: + raise ConfigurationError(f"{chain.id} is a {chain.environment} chain but this profile is {network}") if not isinstance(url, str) or not url.startswith(("https://", "http://")): raise ConfigurationError(f"RPC URL for {chain_id!r} must start with https:// (or http:// for a local node), got {url!r}") diff --git a/bridge-sdk/tests/test_pause_aleo.py b/bridge-sdk/tests/test_pause_aleo.py index cdf24cd4..862f611a 100644 --- a/bridge-sdk/tests/test_pause_aleo.py +++ b/bridge-sdk/tests/test_pause_aleo.py @@ -30,7 +30,7 @@ def test_undeclared_mapping_is_a_configuration_error(): def test_missing_program_is_a_configuration_error(): - with pytest.raises(ConfigurationError, match="does not declare mapping"): + with pytest.raises(ConfigurationError, match="is not served by this node"): _flag(_bridge({}, missing_programs={WARP})) diff --git a/bridge-sdk/tests/test_pause_status.py b/bridge-sdk/tests/test_pause_status.py index 92104b50..6a70632a 100644 --- a/bridge-sdk/tests/test_pause_status.py +++ b/bridge-sdk/tests/test_pause_status.py @@ -113,8 +113,8 @@ def test_destination_evm_falls_back_to_registry_public_rpc(monkeypatch): def factory(url): urls.append(url) - return Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): boolean(False)})) - monkeypatch.setattr("aleo_bridge.pause.Ethereum", factory) + return fake_web3(calls={(XRESERVE, sel("paused()")): boolean(False)}) + monkeypatch.setattr("aleo_bridge.pause._public_web3", factory) b = make_bridge(ethereum=None, mappings={"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {}}}) status = b.pause_status("xreserve:aleo/usdcx->ethereum/usdc") assert not status.paused and not status.unknown @@ -129,8 +129,8 @@ def test_public_rpc_rotation_on_transient_errors(monkeypatch): def factory(url): urls.append(url) scripted = requests.ConnectionError("down") if url == public[0] else boolean(False) - return Ethereum(w3=fake_web3(calls={(XRESERVE, sel("paused()")): scripted})) - monkeypatch.setattr("aleo_bridge.pause.Ethereum", factory) + return fake_web3(calls={(XRESERVE, sel("paused()")): scripted}) + monkeypatch.setattr("aleo_bridge.pause._public_web3", factory) b = make_bridge(ethereum=None, mappings={"usdcx_bridge_v2.aleo": {"paused": {}}, "usdcx_stablecoin.aleo": {"pause": {}}}) status = b.pause_status("xreserve:aleo/usdcx->ethereum/usdc") # first retry is immediate, so no real wait assert not status.unknown and urls == [public[0], public[1]] @@ -180,3 +180,17 @@ def test_warp_program_pause_mapping_follows_what_it_declares(): mappings={bat: {"remote_routers": {"1u32": "x"}}, shield: {}}) with pytest.raises(ConfigurationError, match="declares no pause mapping"): b.pause_status("hyperlane:aleo/bat->ethereum/bat") + + +def test_token_program_the_node_does_not_serve_is_a_configuration_error(): + """Review finding: a privacy/token program missing from the node must never be skipped as 'no switch'.""" + b = make_bridge(ethereum=Ethereum(w3=fake_web3(calls=_hook_tree(False))), + mappings=_warp("hyp_warp_token_eth_v2.aleo", 1), missing_programs={"arc20_eth.aleo"}) + with pytest.raises(ConfigurationError, match="arc20_eth.aleo is not served"): + b.pause_status("hyperlane:ethereum/eth->aleo/eth") + + +def test_public_endpoint_connections_use_a_short_request_timeout(): + from aleo_bridge.pause import PUBLIC_RPC_TIMEOUT_SECONDS, _public_web3 + w3 = _public_web3("https://ethereum-rpc.publicnode.com") + assert w3.provider.get_request_kwargs()["timeout"] == PUBLIC_RPC_TIMEOUT_SECONDS == 10 diff --git a/bridge-sdk/tests/test_profile.py b/bridge-sdk/tests/test_profile.py index e805f253..8d0c9cf1 100644 --- a/bridge-sdk/tests/test_profile.py +++ b/bridge-sdk/tests/test_profile.py @@ -90,3 +90,19 @@ def test_rpc_urls_persist_and_old_profiles_load_without_them(tmp_path, monkeypat assert Profile.load_or_create(tmp_path / "old").rpc_urls == {} with pytest.raises(ConfigurationError, match="https"): p.set_rpc_url("base", "ftp://nope") + + +def test_set_rpc_url_rejects_chains_that_would_break_from_profile(tmp_path, monkeypatch): + """Review finding: a typo'd, non-EVM or wrong-environment chain id must be refused, not persisted.""" + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + p = Profile.load_or_create(tmp_path / "h") + with pytest.raises(ConfigurationError, match="unknown"): + p.set_rpc_url("etherum", "https://x.example") + with pytest.raises(ConfigurationError, match="not an EVM chain"): + p.set_rpc_url("aleo", "https://x.example") + with pytest.raises(ConfigurationError, match="mainnet"): + p.set_rpc_url("sepolia", "https://x.example") + with pytest.raises(ConfigurationError, match="unknown"): + Profile.load_or_create(tmp_path / "h2", rpc_urls={"nochain": "https://x.example"}) + assert p.rpc_urls == {}