Skip to content

bridge-sdk: aleo-bridge-sdk Python package (lifecycle verbs, agent tools, live suite) - #71

Open
iamalwaysuncomfortable wants to merge 94 commits into
masterfrom
feat/bridge-sdk
Open

iamalwaysuncomfortable wants to merge 94 commits into
masterfrom
feat/bridge-sdk

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

Summary

Adds bridge-sdk/ — the aleo-bridge-sdk Python package (import aleo_bridge), a port of veil's @provablehq/aleo-bridge-sdk 0.1.0 into the web3.py-style verb structure used by our other Python SDKs. It is a standalone package: no dependency on shield-swap journal/profile/stage abstractions.

  • Registry pinned to 2026-08-31.solana-deposits.1 (7 chains, 19 assets, 22 routes; metadata literal-identical to veil).
  • Encodings + protocol modules: Hyperlane (Aleo/EVM/Solana), xReserve (EVM deposits incl. private mint, Aleo burns), Sealance freeze-list proofs, ARC-20 shield/unshield.
  • Connections: Ethereum(rpc_url | w3, private_key | signer), Solana(rpc_url | client, private_key | signer) (own sync JSON-RPC client; solana-py is async-only), Bridge.from_env() / from_profile().
  • Lifecycle verbs on Bridge: quote → execute → wait / get_status → recover → resume / complete, veil's checkpoint format (version 1 allowlist, secrets excluded), timeout ≠ failure, calls are single-use after a broadcast, a lost RPC response never loses the tx id.
  • Agent surface: aleo_bridge.agent.bridge_tools()/dispatch_tool() (confirm-gated writes, secrets redacted), stdio MCP server (aleo_bridge.mcp), generated AGENTS.md (codegen/gen_context.py --check in CI), python -m aleo_bridge.
  • Live suite mirroring veil's test/integration/live/: same gates (BRIDGE_LIVE_FUNDS, BRIDGE_LIVE_STATE_DIR, BRIDGE_LIVE_MAINNET_ACK, BRIDGE_LIVE_MAINNET_CASES, BRIDGE_LIVE_MAINNET_EXECUTE), recover-first state files, one atomic unit per leg, parametrized over every registry route in both directions; scripts/rehearse.py as the operator CLI.

Test evidence

  • Hermetic: 649 passed (pytest -q -m "not live"), 51 live tests collected and gated; codegen/gen_context.py --check clean.
  • Live reads on mainnet: Aleo (16), Ethereum (8), Solana IGP decode + leg-11 quote.
  • Real round trip on testnet (2026-09-18), driven through the public Bridge verbs with a process boundary between execute and completion:
    • Sepolia USDC → Aleo-testnet USDCx (private mint, 3 USDC): approval 0x6a89c5fa…8596, deposit 0x08cd56e4…aab4, mint at1jdy6wyal4ndwwydy80hjxt5q9eh332vk6t8zrw8jsvgc0pg7jcxqc85day.
    • Aleo-testnet USDCx → Sepolia USDC (private burn 2.000001): burn at1cufuy7v4lc4dn5ek3vdfa0tpeh8rrqqnahyelv06d2ll5n0kvczsrv9ugq, withdrawal 0xbacb3ff2…4b54.
    • Re-running both tests is an idempotent no-op from the saved checkpoints.
  • Mainnet: all 10 active routes quote successfully in both directions; fund-moving legs are skipped as underfunded until the operator funds the wallets (see below).

Known findings

  • The registry's xReserve withdrawalFeeAtomic (2 USDC) does not match the live testnet fee (≈1.0035 USDC delivered 0.996501 of 2.000001). veil uses the same literal. The quote marks the fee as estimated; the literal is kept for the burn-minimum guard.
  • Private-record selection (Aleo-origin xReserve burns) requires registering the account with the hosted record scanner (aleo.records.register, shares the view key) — documented and opt-in.
  • Delegated proving needs PyNaCl; aleo-sdk[dps] is now a base dependency.
  • 10 routes are metadata-required in the registry (ALEO token to Ethereum/Solana/Base/HyperEVM, USAD) and cannot move funds until reviewed.

Funding needed to run the full mainnet matrix once

Chain Needs
Aleo ~40 credits (four Aleo-origin Hyperlane legs pay 8.2 / 9.1 / 9.1 / 7.7 credits of IGP)
Ethereum ~0.03 ETH gas, ≥ 3 USDC, any USDT
Solana ≥ 0.01 SOL

Rulings made during the agentic loop

71 rulings (decisions I made where the spec, the plans or a review disagreed)

plan 1-core-aleo (11)

  • Task 1 must NOT recreate bridge-sdk/.venv (already provisioned); it only runs the editable install once pyproject.toml exists — why: saves minutes and avoids re-downloading; cost if wrong: none (the editable install is idempotent).
  • Process.add_program / contains_program take Program / ProgramID objects, not strings — plan text says "raw_program"; implementers wrap with net.Program.from_string / net.ProgramID.from_string — cost if wrong: a TypeError caught by the fake-facade tests.
  • contract §types.py binds; the brief's sample code under-implemented it — fix both (derive error from protocol_state["destinationError"] / ["sourceError"] / default; export all 25 names) — cost if wrong: none, additive.
  • (a) match ONLY "already exists" (case-insensitive), as veil-core's DuplicateTransactionError does; "duplicate serial number/output id" stays a real failure — cost if wrong: an idempotent rebroadcast surfaces as an error the caller can retry. (b) keep raising TransactionConfirmationTimeout (consistent with the facade and shield-swap; timeout ≠ failure is the LIFECYCLE's job via submit_prepared(wait=False) + get_status) but document it on delegate/submit_prepared and add a fake-timeout test asserting the exception propagates and the tx was submitted — cost if wrong: none.
  • one fix wave (final-fix-wave-brief.md) dispatched after plan 2 Task 2's implementer reports (one implementer at a time) — cost if wrong: plan 2 builds briefly on unfixed types.Receipt.replace (plan 2 tests do not rely on aliasing).
  • add Task 9b (after Task 11; no parallel implementers): FreezeList resolves the freeze-list program from the token program's imports (bridge.program(token).imports entry ending in freezelist.aleo), with a static fallback table {usdcx_stablecoin.aleo→usdcx_freezelist.aleo, test_usdcx_stablecoin.aleo→test_usdcx_freezelist.aleo}; tree() verifies the computed root against on-chain freeze_list_root when readable and raises ConfigurationError on mismatch (a wrong proof burns a fee) — cost if wrong: an extra mapping read per proof. Ruling: client.ethereum_from_env() / client.solana_from_env() are thin hooks returning None in plan 1 (no connection classes yet); plans 2/3 route them through Ethereum.from_env() / Solana.from_env() per the cross-plan ruling — cost if wrong: a two-line edit later. Facts passed: Transaction.to_json()/from_json(), .id() is a METHOD, .bytes(); network.submit_transaction does str(transaction) so a JSON string is accepted; DPS payload nests the full tx under "transaction"; Process.add_program(Program)/contains_program(ProgramID) take typed objects.
  • Bridge.eth/Bridge.sol stay properties raising ConfigurationError when unconfigured (contract); plan 4 must gate on bridge.ethereum is None / bridge.solana is None (contract attributes) instead of getattr(bridge, "eth", None), and plan 4's FakeBridge must model eth/sol as raising properties so the suite catches this — why: keeps the actionable error for Tier-2 callers while giving lifecycle a None-safe probe — cost if wrong: a wrong DeliveryUnknownError path, caught by plan 4 tests.
  • plan 3 SolCall.send(on_checkpoint=…) passes a Checkpoint built by checkpoint.create_checkpoint, same as plan 2's EvmCall — why: spec §8 convention, one idiom for stores — cost if wrong: none (plan 3 not started).
  • spec §3.2 + plan 4 README describe the Solana transport as the SDK's sync SolanaRpcClient (requests) or a caller-supplied solana-py AsyncClient (adapted); solana.rpc.api.Client does not exist in solana-py 0.40 — cost if wrong: docs only.
  • plan 2 Task 9 extends Bridge.from_profile(..., ethereum=None) to pick up EVM keys via Ethereum.from_env() like plan 3 does for Solana; both Ethereum.from_env() and Solana.from_env() return X | None (None when the key env var is unset; ConfigurationError when only one of EVM_PRIVATE_KEY/ETHEREUM_RPC_URL is set) — cost if wrong: small API asymmetry.
  • nits fixed in plan text (rehearse.py __import__ placeholder and dead branch; duplicate tests/fakes/__init__.py creation made idempotent).

plan 2-ethereum (14)

  • checkpoint.py (Checkpoint dataclass, create_checkpoint, CheckpointStore, FileCheckpointStore) is executed as plan 4 Task 2 BEFORE plan 2 Task 2, under the plan-4 workspace/ledger, because EvmCall.send and SolCall.send both depend on it — why: the contract puts it in plan 4 but plans 2/3 consume it; building it once first avoids stubs — cost if wrong: plan 4's later tasks find it already done (they must check the plan-4 ledger).
  • tests/fakes/fake_web3.py imports FakeAleo from tests.conftest rather than redefining it — cost if wrong: a small import cycle to untangle.
  • commit scope is bridge-sdk for every plan (plan 1 precedent) — cost if wrong: none.
  • finding REJECTED on semantics — veil's recoverDispatchFromHistory({required:true}) makes only the inability to scan fatal (missing sender / no confirmed approval block); a clean zero-match scan means "nothing was dispatched, resume may re-authorize" and must NOT raise (raising would make resume impossible after an approval-only checkpoint). The controller's dispatch text mis-stated this. Code stands; small round queued (behind Task 10): docstring stating the semantic + a test for both protocols asserting required=True + clean zero-match scan → SOURCE_SUBMISSION_PENDING — cost if wrong: a resume that re-dispatches when a scan silently missed a dispatch (mitigated by the sender/to filters and the bounded scan window).
  • for the READ-ONLY live tests (Task 10) the controller supplies ETHEREUM_RPC_URL=https://ethereum-rpc.publicnode.com (chainId 0x1 verified) and SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com (0xaa36a7 verified) at run time; fund-moving leg 1 still needs the user's EVM_PRIVATE_KEY — cost if wrong: a rate-limited public RPC makes a live read flaky (skip on 429).
  • F4 implemented as plan= kwarg (not bare route=/sender=) — plan 4 execute/resume hold a Plan and the registry trust model re-resolves by id — cost if wrong: one kwarg to rename.
  • F5 chunk = 5_000 blocks, no upper cap on total scan (recovery must not refuse) — cost if wrong: slow recovery on very old checkpoints.
  • Bridge.status() keeps propagating ChainMismatchError (loud misconfiguration beats a silently degraded read) — cost if wrong: one try/except in client.py.
  • EvmXReserveQuote.fees stays (); plan 4 quote must render max_fee_atomic as the protocol fee line — carried to plan 4 dispatch notes.
  • _plan_for promoted to aleo_bridge/_plan.py:build_plan now (plan 3 _make_plan and plan 4 prepare must import it; byte-identical Plans are a correctness requirement for _route_for_plan comparisons) — cost if wrong: a module move.
  • M12 amended — build_plan derives the wallet executor from registry.chain(source.chain_id).family (evm-wallet / solana-wallet / aleo-wallet) so plan 3 _make_plan and plan 4 prepare share one builder; EVM output byte-identical — cost if wrong: one string mapping.
  • concern 5 accepted — log_scan_chunk_blocks stays on EthModule; plan 4 may thread it through Bridge(...) if the rehearsal needs it — cost if wrong: one kwarg.
  • (a)+(b) are one-line guards; fold them into the first plan 4 task that edits eth.py (or plan 4's final fix wave), not a third plan 2 round — cost if wrong: two footguns live a few hours longer.
  • (c) is a plan 4 obligation — execute(plan)/resume for private xReserve must take the secret nonce from the intent/checkpointed hook and enforce veil's hookData-equality guard; never rely on the default nonce — cost if wrong: a private mint committed to the wrong hook (funds-adjacent; flagged in the plan 4 dispatches).

plan 3-solana (12)

  • fake_solana.py must not redefine an Aleo fake; reuse tests.conftest.FakeAleo — cost if wrong: a small import.
  • live Solana reads on the public RPC treat HTTP 429/5xx as pytest.skip (rate limit), not failure — cost if wrong: a silently skipped live check (reported in output).
  • Task 5 waits for the plan 2 fix wave (M12 promotes _plan_for_plan.build_plan; SolModule._make_plan must call it so Plans are byte-identical across chains) — cost if wrong: idle implementer slot for one round.
  • an invalid blockhash with no signature status → Status.EXPIRED (+ blockhashExpired/sourceError, no exception), as the brief and Tasks 7/9 specify; controller note 4's "pending" wording applied only to the plain deadline timeout — cost if wrong: one status mapping.
  • fix round 1 (resume implementer) covers the two Importants + the regex right boundary (?![0-9a-fA-F]) + a test for each; no second checkpoint after the message id is known (Task 7 source_status/plan 4 get_status re-derive it from logs) — cost if wrong: a store record without messageId until the next refresh.
  • fix round 1 is serialized behind Task 8 (both edit sol.py) — cost if wrong: idle slot for one round.
  • Task 9 (gated live test + README only) is reviewed inside the plan 3 whole-branch final review instead of a separate task review — cost if wrong: one README nit slips to the final fix wave.
  • I1 and I2 are fixed symmetrically on the EVM side (eth.py/_calls.py EvmCall) in this fix wave even though plan 2 is complete — same shape, same funds hazard, one review — cost if wrong: a slightly larger re-review diff.
  • M10 done by deriving the Solana status row from the registry (append only when a solana-family chain exists in the environment) — cost if wrong: one conditional.
  • plan 3 fix wave gains M11 — SolModule.quote_transfer_remote/transfer_remote take recipient=None with plan= and reject plan+sender with ValueError, so lifecycle calls are uniform across chains — cost if wrong: one kwarg default.
  • arming the single-use guard on an ambiguous (lost-response) send is correct — that is the retry that double-spends — cost if wrong: a caller must construct a new call after a transport failure.
  • one micro fix round (resume the fix-wave implementer): set broadcast_id = local_hash on the echo-mismatch branch + a resend test; fix the adapter _closed ordering + timeout on the client close; leave the middleware path (hash unknowable; documented) and the fake-nonce note — cost if wrong: two small diffs. Serialized behind plan 4 Task 4 (it edits eth.py).

plan 4-lifecycle-agent (34)

  • plan 4 Task 2 runs out of order (first) — why: it is a leaf dependency of plans 2/3 with no plan-4 prerequisites (prepare from Task 1 is not needed by create_checkpoint, which takes a Plan) — cost if wrong: Task 1/3 later find checkpoint.py present; they must not overwrite it.
  • lifecycle quote/execute/resume call eth.*/sol.* with plan=plan (never re-derived asset/recipient/amount); Task 3's FakeEth/FakeSol accept and validate plan= the way the real modules do — cost if wrong: fakes and real modules diverge silently.
  • one Plan builder — lifecycle.prepare parses intent, resolves route + amount, then returns aleo_bridge._plan.build_plan(...); Task 7 _plan_from_intent uses build_plan too; add a test that prepare() == build_plan() field-by-field for every active route — cost if wrong: a recovered plan fails _assert_plan_matches.
  • Task 9 ADDS to __init__.__all__; never replaces; add a test pinning the pre-existing export set (AleoCall, EvmCall, CircleClient, EthModule, FreezeList, EMPTY_MERKLE_PROOF_PAIR, HyperlaneModule, PrivacyModule, XReserveModule, DEFAULT_ENDPOINT, Profile, Locator, Privacy, CALLER_BOUNDARIES, TERMINAL) — cost if wrong: silent public-API regression.
  • EvmXReserveQuote.max_fee_atomic is rendered as the protocol fee line in rehearse._print_quote and agent._serialize (label "xReserve max fee", asset usdc) — cost if wrong: an operator submits leg 3 without seeing the fee.
  • rehearse._destination_reader probes bridge.ethereum is not None / bridge.solana is not None before touching bridge.eth/bridge.sol — cost if wrong: rehearsal crashes on Aleo-only configs.
  • tests/fakes/fake_bridge.py extends tests.conftest.FakeAleo/FakeNetwork (adds get_confirmed_transaction, duplicate-broadcast raises aleo.AleoNetworkError) instead of defining a second Aleo fake — cost if wrong: two drifting fakes.
  • the two parked eth.py guards (recipient None without plan → InvalidRecipientError; log_scan_chunk_blocks setter guard) are folded into plan 4 Task 4 — cost if wrong: none beyond a slightly larger Task 4 diff.
  • plan 4 Tasks 3-8 and 12 dispatch only after plan 3 Tasks 7-9 land SolModule.source_status, exports and Bridge wiring; plan 4 Task 1 may run any time — cost if wrong: dead real code paths behind passing fakes.
  • log_scan_chunk_blocks setter raises ConfigurationError (matches the constructor; one contract) — the note said ValueError — cost if wrong: one exception class.
  • _Emitter dedupes on checkpoint equality (both channels reduce the same receipt against the same plan), not (id, status) — cost if wrong: a duplicate callback.
  • the two Importants + the unguarded balance read are folded into Task 6's dispatch (it edits lifecycle.py next; Task 5 is mid-flight on the same file) — cost if wrong: Task 6's diff grows by ~30 lines.
  • the Important test + the two one-line minors (receipt.id != receipt.source_tx_id guard; _module outside the try) are folded into Task 7's dispatch — cost if wrong: Task 7's diff grows by ~20 lines.
  • DeliveryUnknownError when a baseline exists but no destination reader is configured is correct (veil parity; a false "still pending" is worse) — cost if wrong: one exception → unchanged mapping.
  • plan 4 Task 13 is the critical deliverable — tests/live/test_lifecycle_live.py + scripts/rehearse.py must exercise EVERY lifecycle verb (prepare/quote/execute/wait/get_status/recover/resume/complete, agent tools' read path) against real chains per spec §12's leg matrix at minimal amounts, with balance prechecks and per-leg tx ids in the report; the fakes-only tests are not sufficient evidence of "done". Testnet legs (Sepolia leg 1, Aleo testnet) run unconditionally; mainnet fund-moving legs run in this session with BRIDGE_LIVE_MAINNET_EXECUTE set on the rehearsal invocation only (never exported), because the user has explicitly asked for actual e2e tests and the standing rule (memory: live round trips at minimal amounts are the definition of done; keys from env) authorizes it — cost if wrong: minimal mainnet fees on the user's own test wallets, every tx id reported.
  • recovery verbs get real e2e coverage too — the rehearsal interrupts a leg after the source checkpoint (kill/resume) and continues via Bridge.recover/wait/resume/complete from the on-disk checkpoint, so recover/resume/complete are validated against live state, not fakes.
  • the live suite mirrors veil's integration/live tests — same route coverage (every registry route, both directions, on mainnet), same leg structure and amounts where veil specifies them, ported to the Python verbs; spec §12's 13-leg matrix is superseded wherever veil's matrix is broader. An inventory of veil's live tests is being taken before Task 13's brief is rewritten — cost if wrong: Task 13 grows; funding requirements grow with it (reported to the user).
  • fold the fix into Task 8's dispatch (move the swallow to _delivery_verification's call site in execute; get_status sees the real exception) — cost if wrong: Task 8's diff grows by ~10 lines.
  • _finish stays storeless (a read-only recovery must never raise from create_checkpoint; wait/get_status persist) — cost if wrong: one extra log scan after a restart.
  • Task 9 adds the divergent-id terminal-cleanup test (Solana EXPIRED with a FileCheckpointStore) and the CheckpointInvalidError guard for malformed deliveryVerification — cost if wrong: two small additions.
  • Task 13 is dispatched in two halves — 13a: live harness (tests/live/config.py/helpers.py mirroring veil's gates + state files + explorer lookup, hermetic tests/test_live_helpers.py, the per-case functions, scripts/rehearse.py CLI); 13b: the parametrized live suite tests/live/test_lifecycle_live.py over every active route plus the first real runs (testnet immediately; mainnet when the wallets are funded and the user's acknowledgements are set) — cost if wrong: one extra review seat.
  • is_duplicate_broadcast_error is exactly plan 1's is_duplicate_submission (only "already exists"); a bare "Duplicate transaction" raises — the safer direction — cost if wrong: a manual resume after a benign duplicate.
  • complete does not call _require_active (funds are already deposited; XReserveModule still validates the route) — cost if wrong: a mint attempted on a parked route that the module then refuses.
  • Task 8 fix round 1 (resume the implementer, after Task 9 lands — same file): missing/non-hex hookData on an xReserve resume → NotResumableError + test; add a FileCheckpointStore assertion to the complete secret-absence test; one docstring line on SOURCE_APPROVAL_PENDING → use recover() — cost if wrong: ~15 lines.
  • Task 10 adds the bridge_tools/dispatch_tool/agent_guide package exports; Task 12 updates __main__.py to print the generated guide — cost if wrong: none (same files, later tasks).
  • pending() builds each Progress OFFLINE — Checkpoint → its reduced receipt/intent → to_progress(plan, receipt) with plan from _plan_from_intent (pure) — and returns a list even when a checkpoint is malformed (that entry becomes a Progress with next="failed" and the error text); callers refresh with get_status/recover — cost if wrong: a stale next until the caller refreshes.
  • hook data (a public BHP commitment) may appear in quote results and checkpoints — resume needs it and it opens nothing without the secret nonce; receipts rendered by tools still drop it — cost if wrong: none (not a secret).
  • bridge_complete may run without a secret_nonce only when the mint is already proved (preparedDestinationTransaction rebroadcast) — cost if wrong: none (no nonce is used on that path).
  • finish = plan 4 complete (Tasks 11, 12, 13a, 13b, 14 + final whole-branch review + fix wave), hermetic suite green, the live suite run as far as funding allows with results in the PR body; then push worktree-bridge-sdk to origin and open a PR against master (title/body without any AI attribution; body lists the leg-by-leg live results, the funding still needed for the unrun mainnet legs, and the collected rulings) — cost if wrong: a PR the user closes.
  • fix round 1 (resume the implementer) covers both Importants + deepcopy of schemas + next: "recover" guidance on bridge_resume/bridge_complete errors + bridge_pending delegating to the (now offline) Bridge.pending() with per-entry checkpoints and per-entry failures; queued behind Task 9's fix round — cost if wrong: ~40 lines.
  • review load cut for the rest of plan 4 — no per-task review for 13a/13b/14; the real-chain runs in 13b are the gate, then ONE whole-branch final review (opus) + one fix wave — cost if wrong: a plumbing defect reaches the final review instead of a task review.
  • the user's "you can run the bridge tests" is the acknowledgement for mainnet execution — set BRIDGE_LIVE_MAINNET_EXECUTE (and ACK/CASES/FUNDS/STATE_DIR) on the rehearsal/pytest invocation only, never exported, and only once a leg's balance precheck passes; every leg currently skips as UNDERFUNDED, so nothing moves until the three existing addresses are topped up (table given to the user) — cost if wrong: minimal-amount mainnet legs the user explicitly asked for.
  • mainnet execution is authorized (execute acknowledgement on the invocation only). Balances re-read now are UNCHANGED (Aleo 1.630993 credits, ETH 0.0000687, USDC 0, USDT 0, WBTC 37_310 sat, SOL 650_240 lamports) — every fund-moving mainnet leg still fails its quote-time precheck, so the run would only produce Underfunded skips. Action: a background balance monitor watches the three addresses; the moment any wallet crosses its threshold the mainnet cases that it funds are dispatched with BRIDGE_LIVE_MAINNET_EXECUTE set on that invocation; the user is told the exact addresses/amounts again — cost if wrong: none (no funds move until funded).
  • (1) and (2) go into the plan 4 final fix wave as Important items regardless of what the reviewer finds; (5) as Minor — cost if wrong: a slightly larger fix wave.

Review status

Every task had a fresh-implementer + task-review cycle (funds-critical fixes from those reviews are on the branch); a final whole-branch review is in flight and its fix wave will be pushed to this branch.

…mported modules

del sys.modules[...] permanently replaced aleo_bridge's module/class objects for
the rest of the pytest session, breaking isinstance checks in any test file that
runs later alphabetically and compares against classes imported at collection
time. Use monkeypatch.delitem so the original modules are restored at teardown.
…d nullifier reads, registry drift) and README
Add checkpoint.py: Checkpoint (frozen dataclass with to_dict/to_json and
from_dict/from_json round-tripping), create_checkpoint(plan, receipt, registry)
reducing a Receipt to the documented recovery allowlist (intent, route,
source/destination transaction ids, delivery verification) while excluding
keys, record plaintext, secretNonce, attestation bodies, payloads, message
hashes, nonces and quote internals, CheckpointStore protocol, and
FileCheckpointStore (one 0600 file per sanitized receipt id, atomic
temp+os.replace writes).

Export Checkpoint/CheckpointStore/FileCheckpointStore/create_checkpoint from
__init__.py. Update the two test_client.py cases that were placeholders for
plan 4's checkpoint wiring (BRIDGE_CHECKPOINT_DIR, Profile.checkpoint_dir) now
that checkpoint.py exists and client.py's lazy imports resolve.
…e JSON-RPC web3 double

Adds aleo_bridge.eth.Ethereum (rpc_url+private_key, w3+signer, or bare w3
read-only/default-account forms), cached chain_id, send_transaction (local
signer or default-account path), wait_for_receipt/get_receipt, and
Ethereum.from_env. web3/eth_account stay lazily imported so the package
still imports without the evm extra.

Adds tests/fakes/fake_web3.py: a real Web3 over a hand-rolled JSON-RPC
provider so tests exercise real signing/calldata against fake transport
state, reusing tests.conftest.FakeAleo rather than a second Aleo fake.

Updates the two plan-1 placeholder assertions in test_client.py that
anticipated this change (Bridge.eth / Bridge.from_env now construct a
real Ethereum connection instead of raising MissingExtraError).
private_burn's record plaintext (input 0) and private_mint's secret nonce
(input 3) could end up in logs via repr(). Print only the input count;
.inputs stays the explicit accessor for callers that need the values.
… ConfigurationError

Receipt.replace() forwarded protocol_state/next_action straight to
dataclasses.replace() unless the caller overrode them, so a replace() copy
aliased the original's dicts and mutating one leaked into the other.
setdefault() a shallow copy of each first.

to_progress()'s missing-routeId check raised a bare ValueError; every other
failure in this package is a BridgeError subclass, so make this one
ConfigurationError too (same message).
_verified_tree() previously fell back to an unverified (possibly empty)
Merkle proof whenever the on-chain freeze_list_root was unreadable, which
would silently hand out a proof against the wrong tree. Raise
ConfigurationError instead and tell the caller to pass merkle_proof=
explicitly.

freeze_list_program()'s import-lookup also narrowed its except clause from
bare Exception to (ProgramNotFound, AleoError), so a transient RPC failure
of another type propagates instead of silently falling back to the static
freeze-list table.

Updated every fake facade setup that expects a proof to seed
usdcx_freezelist.aleo/freeze_list_root[1u8] with the matching root
(EMPTY_TREE_ROOT for an empty list), and added tests for the unreadable-root
and unrelated-exception-propagates cases.
…e 0700

Profile.load_or_create()'s first-write path used a tmp-file-plus-rename,
which is atomic but not exclusive: two processes racing a fresh
$ALEO_BRIDGE_HOME could each "win", the second silently overwriting the
first's already-in-use key. Open profile.json with O_EXCL instead; on
FileExistsError, load whichever profile got there first instead of
clobbering it. The home directory is now created (or healed) at mode 0700,
matching the file's existing 0600.
…tate

burn() silently ignored record=/merkle_proof= for public/public-as-signer
modes instead of rejecting them, and an unknown mode wasn't caught until
deep inside build_burn_inputs (after route/amount/chain work already ran).
Validate mode first, and reject record=/merkle_proof= for any non-private
mode with a ConfigurationError.

Also de-duplicated the Aleo-chain lookup: XReserveModule._aleo_chain_id()
and HyperlaneModule._aleo_chain() were exact copies of Bridge.aleo_chain();
both modules now call the client's version instead of keeping their own.
- units._DECIMAL_RE: \d -> [0-9] (Python's \d matches any Unicode decimal
  digit, not just ASCII).
- encoding.xreserve_deposit_nonce: bound source_domain to uint32 before
  encoding it, consistent with the payload's remote_domain check.
- registry.Asset.matches_address: re.fullmatch instead of re.search, so a
  trailing newline can no longer sneak past a "$"-anchored regex.
- encoding's hex/shape/width checks (hex_to_bytes, bytes32_to_u128_limbs,
  solana_address_to_hyperlane_recipient, xreserve_deposit_payload's
  per-field width check) now raise InvalidRecipientError, and _uint_be
  raises InvalidAmountError, instead of bare ValueError — all BridgeError
  subclasses, same remedy messages. Updated the callers (circle.py,
  hyperlane.py, xreserve.py) that caught the old ValueError type, and the
  tests that asserted on it. xreserve.get_attestation("nope") now raises a
  BridgeError instead of a naked one.
- client.checkpoints_from_env: removed the dead ImportError branch (and its
  "plan 4" wording) now that checkpoint.py always exists.
- client.balance_program: one-line comment recording that arc20_<sym>.aleo's
  balances mapping is verified to be what the warp/xreserve programs'
  mint_public/burn_public spend.
- hyperlane.py: removed the unused Callable import.
…and checkpoint-before-poll ordering

- fake_web3._decode_raw now keeps nonce/gas/gasPrice/maxFeePerGas/maxPriorityFeePerGas from the
  signed raw tx instead of discarding them, so tests can assert send_transaction's exact fee-filling
  arithmetic (gas = estimate * 1.2; EIP-1559 maxFeePerGas = baseFee * 2 + tip).
- FakeRpcProvider gains a legacy=True knob that omits baseFeePerGas from eth_getBlockByNumber,
  exercising eth.py's previously-unreachable legacy gasPrice branch.
- FakeRpcProvider gains receipt_delay/receipt_poll_counts so a receipt can stay pending for N polls
  per hash; test_evm_call's ordering test now proves each checkpoint fires with zero receipt polls
  for its own hash, that polling only starts after, and that approvals fully confirm before the
  main call is broadcast.

No production code changes: eth.py's fee formula and _calls.py's checkpoint-before-poll ordering
were both already correct; the gap was in test strength only.
… row from the registry

Bridge(solana=...) now takes an RPC URL string as a read-only connection and refuses any
object that is not a solana-py client with a ConfigurationError, instead of wrapping it and
failing later with an AttributeError from the first RPC read.

status() appends the Solana row only when the environment actually has a chain with
family == solana, and takes the chain id and native asset id from that chain rather than
the 'solana' / 'solana/sol' literals — a testnet client no longer reports a chain the
registry does not define for it.
…m __exit__

_AsyncClientAdapter.close() now awaits the wrapped client's own close() on the private loop
before stopping it — solana-py's AsyncClient owns an aiohttp session that can only be closed
from its own loop — and closes the loop only once the thread has actually exited, instead of
raising on a loop that is still running.

SolanaRpcClient gains close(), so Solana.close() releases the HTTP pool of the DEFAULT
transport too, and Solana.__exit__ swallows whatever close() raises so releasing a transport
cannot replace the caller's own exception.
… at build, accept a plan alone

M7: keypair_from_private_key raises its ConfigurationError outside the except block on both
the JSON and base58 paths, so neither __cause__ nor __context__ survives — a chained
JSONDecodeError carries the whole document in .doc, which IS the private key.

M8: _build_transaction re-resolves the route through outbound_route() instead of the one
snapshotted when transfer_remote() was called, and refuses a route that no longer matches the
quoted plan; the instruction can no longer be compiled against a stale deployment.

M11: recipient is optional on quote_transfer_remote/transfer_remote — a plan supplies it
along with the amount and sender. Neither plan nor recipient raises InvalidRecipientError,
plan with sender= or a differing amount raises ValueError (an identical amount is tolerated),
mirroring EthModule's rule.
…arations

README lists BRIDGE_SOLANA_PRIVATE_KEY / BRIDGE_LIVE_SOLANA_RPC_URL as aliases with the same
caveat as the Ethereum pair: they are not live-test-only, so an exported alias points every
from_env() call at that endpoint.

Removes _sealevel.ALEO_MAINNET_HYPERLANE_DOMAIN (nothing imported it; the domain comes from
the route metadata) and the unused decimals parameter of SolModule._make_plan.
…unk_blocks on assignment

Without plan=, eth.quote_transfer_remote/transfer_remote/quote_deposit_usdc/deposit_usdc
now raise InvalidRecipientError instead of letting recipient=None reach _recipient_bytes32
as an opaque TypeError (deposit_usdc did not fail until send()).

log_scan_chunk_blocks becomes a validating property so eth.log_scan_chunk_blocks = 0 cannot
make _scan_logs spin forever; the constructor runs through the same check.
…gs and EVM/Solana dispatch

execute() commits the source-chain legs of a prepared Plan and returns Progress. Aleo legs
prove (DPS or local), checkpoint the exact prepared transaction BEFORE broadcasting it, submit
with wait=False and checkpoint the id; EVM and Solana legs go through the modules' own plan=
surface so the route, registry version, sender and every plan field are re-validated where the
transaction is built.

A private xReserve mint now requires its own secret_nonce (never the 0scalar default), the
Hyperlane hook payment is re-quoted at the last moment unless pinned, and a destination-balance
baseline is captured only when the destination connection is the recipient.

_Emitter feeds both checkpoint channels: a module's already-reduced Checkpoint and execute()'s
own post-send emission reduce to the same value, so the caller sees each boundary once and the
bound store keeps exactly one record per transfer, superseding the id it replaces.

The write-side fakes gain the real plan= signatures, and lifecycle.quote now calls
bridge.sol.quote_transfer_remote(plan=plan) without the positional recipient workaround.
… adapter close survives a raising client

R1: a node that echoes a different hash still ANSWERED, so the signed bytes may sit in its
mempool under the hash we computed — the same ambiguity as a lost response. The mismatch branch
now carries broadcast_id, so EvmCall refuses a resend instead of signing a second transfer.

R2: _AsyncClientAdapter.close() bounds the wrapped client's close() by the timeout, swallows its
failure and stops the loop in a finally, setting _closed only once that stop is issued — a
third-party transport that cannot close no longer leaves the private loop thread running with a
second close() turned into a no-op.

R3: comments the default-account path, where the hash is not knowable until the node answers, so
none of these protections apply; a local signer is the right choice for funds-moving calls.
…connected address, and make destination-balance reads best-effort

Task 4 review carry-overs: _persist now saves (or drops, if terminal) the new
checkpoint before deleting the id it supersedes, never the reverse, so a crash
between the two steps can't leave the store holding neither. A module-emitted
checkpoint's supersede is deferred on the _Emitter (_pending_supersede) until
the module has actually saved it -- flushed at the next emission or via
execute()'s new finalize() call.

Aleo hyperlane/xreserve legs now call _assert_sender against a guarded
bridge.aleo_address() before proving, refusing a plan built for a different
account. _read_destination_balance wraps its RPC read in try/except so an
advisory baseline read never blocks funds movement.
…or tolerance and timeout-as-not-failure

Polls get_status() to a caller boundary (types.CALLER_BOUNDARIES, extendable
via until=) or a terminal state, tracking observed changes into the bound
checkpoint store and firing on_update only when the receipt actually changed.
A transient read error (flaky RPC/HTTP transport) is retried with the normal
poll interval up to max_consecutive_errors (default 5) before the last one is
re-raised; on_error lets callers log the retries. Everything else propagates
on the first attempt. Hitting timeout_seconds raises PollingTimeoutError
carrying the last status/progress -- a timeout is not a failure, the transfer
is still in flight.
…swallowing a missing Solana connection

_message_id no longer falls back to receipt.id when it equals receipt.source_tx_id (an EVM
Hyperlane receipt whose DispatchId log was unreadable carries the tx hash as its id, not a
message id). get_status's Solana message-id fill-in now resolves the sol module outside the
log-read try, so a missing Solana connection raises ConfigurationError instead of being
swallowed as "still unavailable". Also adds the carried "delivered wins" regression test for
get_status's inbound xReserve nullifier-first check on ATTESTATION_PENDING.
…t signing

Appends recover(bridge, checkpoint) plus its helpers (_coerce_checkpoint,
_plan_from_intent, _assert_prepared_id, _finish) to lifecycle.py. Accepts a
Checkpoint, its dict, or its JSON; rebuilds the Plan via prepare() against the
live registry and validates route id + registry version before touching any
chain. Branches on source-chain family: Aleo (prepared-but-unbroadcast resumes
with no network read; submitted gets one get_status refresh), Solana
(validates the blockhash pair and reads source_status once), EVM Hyperlane
(delegates to eth.recover_source(plan, cp, required=False)), and inbound
xReserve (delegates to eth.recover_source, then layers a submitted or
prepared private-mint destination leg on top). Checkpoint deletion on a
terminal outcome is keyed on the checkpoint's own id, never the receipt's
(they diverge once a source tx confirms into a message id).

Adds tests/test_recover.py (8 tests) covering checkpoint validation, all
three source-chain families, xReserve destination recovery paths, unsupported
routes, terminal checkpoint cleanup, and a round-trip check that a plan
rebuilt from a checkpoint is field-identical to the plan that produced it for
an EVM, a Solana, and an Aleo-origin route.
The best-effort try/except lived inside _read_destination_balance, so a flaky
RPC looked identical to 'no reader configured' in get_status branch 6 - the
delivery signal degraded to 'not delivered yet' forever instead of being
retried by wait()'s transient classifier.

Move the swallow to _delivery_verification, the one caller that genuinely must
not raise (execute's advisory pre-broadcast baseline).
…rivate mint

resume() finishes the source leg an interruption left unsubmitted. An Aleo leg
rebroadcasts the checkpointed bytes byte-for-byte after checking the serialized
payload's own id, and treats the node's duplicate answer as success (plan 1's
is_duplicate_submission, re-exported as is_duplicate_broadcast_error rather than
reimplemented) - so the transfer can only ever exist once on chain. An EVM leg
re-scans source history with recover_source(required=True) and authorizes only
the step the scan proves is still missing, keeping veil's two guards: the
re-quoted hook data must equal the checkpointed approval's hook, and the
allowance must still cover the deposit. Solana legs have no resumable state and
say so instead of guessing.

complete() submits the one user-signed Aleo transaction a private USDCx mint
needs: it rebroadcasts an already-proved destination transaction, or builds the
mint (the module re-verifies that (recipient, secret_nonce) opens the attested
commitment, so a wrong nonce never reaches proving), proves it, checkpoints the
exact bytes BEFORE broadcast, then broadcasts.

secret_nonce is mandatory for a private resume/complete and is refused before
any RPC; it is never written to a receipt, a checkpoint or a Progress.
…lete never persists secrets (store-level test)

The hook-equality guard in resume's xReserve branch only ran when protocol_state
carried a hookData string, so a receipt without one passed it vacuously and the
deposit could be re-hooked to a freshly derived commitment. Require the hook to
be present and a 65-byte 0x hex string (encoding.HOOK_DATA_BYTES, the same shape
EthModule._recover_xreserve enforces) in the pre-RPC block, before the history
scan, so a transfer resume() cannot finish is not even scanned; the equality
guard below is now unconditional.

Every receipt recover() produces already carries hookData, so this only refuses
hand-built, truncated or legacy ones.

Also: assert at the store level that complete() writes neither the secret nonce,
Circle's attestation nor the committed hook into any checkpoint file, and say in
resume's docstring that a SOURCE_APPROVAL_PENDING receipt needs recover() first.
…ated writes

bridge_tools() returns the six read and five write tools in the Claude API
tools= shape; dispatch_tool() runs one and returns JSON-serializable data.
Reads are open; every write needs confirm=true and otherwise returns the quote
(or recovered progress / built call) plus how_to_confirm, having moved nothing.
bridge_execute takes the quote inputs, never a plan, and re-quotes internally;
confirmed writes hand back {progress, checkpoint} so the model can feed the
checkpoint to bridge_get_progress / bridge_resume / bridge_complete later.

Three safety rules on top of the plain surface:
- a private mint never gets a defaulted secret: mint_mode=private without a
  secret_nonce (and bridge_complete without one, unless the mint is already
  proved) is a structured error that quotes nothing and moves nothing;
- secrets never leave the process: rendered receipts drop the private-mint
  secret, record plaintext, the Circle attestation body and the proved
  transaction bytes, and no tool echoes its own arguments back;
- BridgeErrors come back as {error, error_type, how_to_fix} instead of
  crashing the model's loop - unconfigured Ethereum/Solana connections are
  probed up front, and an ambiguous source send also carries next="recover"
  with the last checkpoint, since these calls are single-use.

EvmXReserveQuote renders a synthetic "xReserve max fee" fee entry from
max_fee_atomic, which its empty fees tuple would otherwise hide.

Also lands the exports task 9 deferred: bridge_tools, dispatch_tool and
agent_guide() (the packaged AGENTS.md, or a pointer at codegen/gen_context.py
until task 12 generates it), with the export-pinning test extended.
…cord previews, copy schemas, guide recovery on write errors
…the hermetic mirror

Ports veil's test/integration/live/config.ts and helpers.ts: the read-only gate
functions (BRIDGE_LIVE_FUNDS + BRIDGE_LIVE_STATE_DIR, the mainnet acknowledgement
and case list, the separate execution acknowledgement), one_atomic_unit, the
namespaced state path, and the state/benchmark/polling/explorer helpers.

The private-mint secret nonce is new relative to veil: it is generated once per
case, written to <state>.secret with O_CREAT|O_EXCL and mode 0600, and the state
JSON records only secretNoncePresent, so a state file can be shared without
leaking the commitment secret.

tests/test_live_helpers.py mirrors veil's helpers.test.ts (gating truth table,
state round-trip and fail-closed cases, one-atomic-unit formatting, bytea hashes,
rejected Aleo transactions) and adds the secret-file mode/exclusivity checks and
the explorer 429/5xx tolerance.
… verbs

tests/live/cases.py ports veil's five mainnet cases as functions the pytest
suite (13b) and the operator CLI both call, so the two cannot drift. Each case
loads its route-qualified state file, quotes, prints the route/amount/fee table,
prechecks balances (Underfunded carries the shortfall so callers can skip),
and — only when the caller passes execute=True — executes once, drops the
in-memory progress, recovers from the checkpoint on disk and drives
wait/resume/complete to done. execute is never retried after a broadcast.

Case parametrization covers every registry route per case, in both directions,
with non-active routes reported as skipped-by-registry rather than dropped.
veil's literals are kept: '2' USDC for the private mint, '2.000001' USDCx for
the private burn, one atomic unit elsewhere, signer mode for Aleo Hyperlane.
The Aleo Hyperlane IGP payment comes from our own quote rather than veil's
operator-supplied fee variable.

scripts/rehearse.py runs one case over its routes with --case/--route/
--quote-only/--recover/--report and exits 0/1/2 (ok/failed/pending). It only
reads the acknowledgement variables, prints no line that would set one, and
prints the --recover command for anything left pending.
Documents the three gates and that nothing in the repository sets them, the
state and secret files, the five cases with their routes and veil amounts, the
funding each run needs (from the read-only mainnet quote sweep), and how to
rehearse, run and recover through scripts/rehearse.py.
…, marks, balance delta

Aleo-origin cases confirm the source transaction on chain before recovering
(veil aleo-hyperlane:153, aleo-xreserve:136), so a rejected execution is
reported as a rejection rather than as a delivery timeout. Adds the
plan-prepared and clients-created benchmark marks veil records, logs the
destination balance delta once a case is done, and looks the checkpoint up in
the bound store once instead of twice.
One `live`-marked test per veil case, parametrized from an enumeration of
DEFAULT_REGISTRY rather than a hand-written route list: a route no case covers
raises at import, and a `metadata-required` route is parametrized and skipped
with `registry:metadata-required` instead of disappearing. Plus the testnet
deposit (3 USDC, the amount that clears the 2 USDCx withdrawal fee) and — beyond
veil — the testnet RETURN burn, so "all the routes back and forth" holds on
testnet too.

Every funded test crosses a real process boundary: phase one quotes, prechecks
and calls `execute` once, then returns; that client is dropped and a brand-new
Bridge over the same FileCheckpointStore finishes the transfer through
`pending()` → `recover()` → `wait`/`resume`/`complete`. `execute` is never
called twice for one transfer. Underfunded is a skip with the shortfall (also
when a quote refuses first), a timeout is pending with the resume command.

Harness changes the first real runs forced:

* `cases.run_case(stop_after_execute=...)` — the handover point above.
* Aleo→EVM xReserve delivery is a balance rise, not a drive loop. lifecycle.py
  says Circle exposes no delivery query for that direction and leaves the
  receipt in DELIVERY_PENDING, so `wait` could only ever time out (it did, for
  20 minutes, on a leg whose funds had already landed). veil polls the ERC-20
  balance instead; so do we now, and we recover the withdrawal tx id from the
  Transfer log.
* config: per-environment key/RPC resolution (testnet Aleo key, Sepolia RPC,
  public defaults) and `BRIDGE_LIVE_XRESERVE_AMOUNT`.
* Delivery is asserted as "at least what was quoted", never equality — the
  testnet return delivered 0.996501 USDC against a quoted 0.000001, because the
  live withdrawal fee was 1.0035 USDC, not the registry's 2.
README: the exact invocations for quote-only, testnet and mainnet execution
(the acknowledgement values shown only as `<see tests/live/config.py>`, never
copy-pasteable), the metadata-required and testnet routes, and the two-phase
recovery the suite performs.

pyproject: PyNaCl moves into the base dependencies. Delegated proving is the
default path, and the proving request is sealed with a NaCl box before it
leaves the machine — without PyNaCl the first testnet private mint raised
ImportError *after* the Sepolia deposit was already on chain. That is not an
optional extra.
…P surface and live evidence

Reorganizes README.md around quote/execute/wait/recover/resume/complete, connections,
routes, shield/unshield, Tier 2 modules, agent tools and MCP, keeping every existing
fact (aliases, single-use calls, timeout-is-not-failure, checkpoint-before-poll,
Solana.close(), the full live-tests gates/cases/funding tables). Adds the 2026-09-18
testnet round trip evidence and the registry-vs-live xReserve fee discrepancy.
Extends tests/test_package.py with version/extras lockstep, AGENTS.md packaging and
README-coverage assertions.
Mirrors build-shield-swap/release-shield-swap: builds the pure-Python wheel against
the built aleo-sdk wheel, runs the hermetic suite only (pytest -q -m "not live" —
no key, no live RPC), checks AGENTS.md via gen_context.py --check, and smoke-tests
the wheel both with every extra and without any (MissingExtraError only at point of
use, never at import). release-bridge is tag-triggered, reuses the trusted-publishing
pattern, and gates on build-bridge like the other release jobs; adds bridge-sdk/** to
the push/pull_request path triggers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant