From cab80a6e0a8e6aec5c398c4bff9dd82b3bce654d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:08:03 -0400 Subject: [PATCH 01/94] feat(bridge-sdk): scaffold aleo-bridge-sdk with error taxonomy and exact units --- bridge-sdk/.gitignore | 5 + bridge-sdk/README.md | 4 + bridge-sdk/docs/veil-brief.md | 326 ++++++++++++++++++++++ bridge-sdk/pyproject.toml | 22 ++ bridge-sdk/pyrightconfig.json | 7 + bridge-sdk/pytest.ini | 7 + bridge-sdk/python/aleo_bridge/__init__.py | 24 ++ bridge-sdk/python/aleo_bridge/errors.py | 93 ++++++ bridge-sdk/python/aleo_bridge/units.py | 60 ++++ bridge-sdk/tests/__init__.py | 0 bridge-sdk/tests/test_package.py | 39 +++ bridge-sdk/tests/test_units.py | 54 ++++ 12 files changed, 641 insertions(+) create mode 100644 bridge-sdk/.gitignore create mode 100644 bridge-sdk/README.md create mode 100644 bridge-sdk/docs/veil-brief.md create mode 100644 bridge-sdk/pyproject.toml create mode 100644 bridge-sdk/pyrightconfig.json create mode 100644 bridge-sdk/pytest.ini create mode 100644 bridge-sdk/python/aleo_bridge/__init__.py create mode 100644 bridge-sdk/python/aleo_bridge/errors.py create mode 100644 bridge-sdk/python/aleo_bridge/units.py create mode 100644 bridge-sdk/tests/__init__.py create mode 100644 bridge-sdk/tests/test_package.py create mode 100644 bridge-sdk/tests/test_units.py diff --git a/bridge-sdk/.gitignore b/bridge-sdk/.gitignore new file mode 100644 index 00000000..5ce617bb --- /dev/null +++ b/bridge-sdk/.gitignore @@ -0,0 +1,5 @@ +.venv/ +dist/ +__pycache__/ +.pytest_cache/ +*.egg-info/ diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md new file mode 100644 index 00000000..c767fad2 --- /dev/null +++ b/bridge-sdk/README.md @@ -0,0 +1,4 @@ +# aleo-bridge-sdk + +Python SDK for bridging assets between Aleo, Ethereum and Solana (Hyperlane warp routes, Circle xReserve). +`pip install aleo-bridge-sdk` → `from aleo_bridge import Bridge`. Expanded in plan 1 Task 12 and plan 4. diff --git a/bridge-sdk/docs/veil-brief.md b/bridge-sdk/docs/veil-brief.md new file mode 100644 index 00000000..9924f6f8 --- /dev/null +++ b/bridge-sdk/docs/veil-brief.md @@ -0,0 +1,326 @@ +# Veil Bridge (`@provablehq/aleo-bridge-sdk` v0.1.0) — Capability Port Brief + +Source: `/Users/privacydaddy/dev/aleo-viem/packages/bridge`. Deps: viem ^2.21, bs58 ^6, @provablehq/veil-core. Optional peers: @provablehq/sdk (private xReserve mints + program-address derivation), @solana/kit ^8. + +## 1. REGISTRY + +### 1.1 Data model (`src/types/protocol.ts`) +``` +BridgeProtocol = 'xreserve' | 'hyperlane' +BridgeEnvironment = 'mainnet' | 'testnet' +BridgeChainFamily = 'aleo' | 'evm' | 'solana' +BridgeAssetKind = 'native' | 'token' +BridgeRouteAvailability = 'active' | 'metadata-required' | 'disabled' +AleoPrivacyKind = 'arc20' | 'arc22' +AleoMintMode = 'public' | 'record' | 'private' +BridgeStepExecutor = 'aleo-wallet' | 'evm-wallet' | 'solana-wallet' | 'protocol' +BridgeExecutionStepKind = approve|deposit|burn|dispatch|wait-attestation|mint|withdraw|wait-delivery|confirm-delivery + +ProtocolBridgeChain { id, displayName, family, environment, nativeCurrencySymbol, protocolDomains?: {xreserve?, hyperlane?} } +BridgeAssetLocator { kind: 'aleo-program'|'evm-contract'|'solana-mint'|'native', value, tokenId? } +AleoPrivacyCapability{ kind: 'arc20'|'arc22', program } +ProtocolBridgeAsset { id, key, chainId, symbol, name, decimals, kind, locator?, addressValidationRegex?, privacy? } +ProtocolBridgeRoute { id, protocol, environment, sourceAssetId, destinationAssetId, availability, deploymentId?, source?, metadata? } +BridgeRegistry { version, chains[], assets[], routes[], sources[], getAssets(), getRoutes() } +``` +getAssets({environment?, chainId?, symbol?}) case-insensitive. getRoutes({environment?, protocol?, sourceChainId?, destinationChainId?, symbol?, includeUnavailable?}) excludes disabled unless includeUnavailable; metadata-required always visible; symbol matches source OR destination. + +Registry version: `'2026-08-31.solana-deposits.1'`. +``` +EVM_ADDRESS = '^0x[0-9a-fA-F]{40}$' +SOLANA_ADDRESS = '^[1-9A-HJ-NP-Za-km-z]{32,44}$' +ALEO_ADDRESS = '^aleo1[0-9a-z]{58}$' +``` + +### 1.2 Validation +Unique chain/asset/route ids; asset chainId exists; scoped key `${chainId}/${key}` unique; decimals int>=0; regex compiles; privacy only on aleo family. Route source/dest assets exist; both chains' environment == route.environment. Solana gate: active hyperlane route with solana source requires warpProgramAddress, tokenPda, nativeCollateralPda, dispatchAuthorityPda, mailboxProgramAddress, mailboxOutboxPda, igpProgramAddress, igpProgramDataPda, igpAccount, splNoopProgramAddress, destinationDomain (number), destinationGasAmount, registryCommit, solanaReviewedAt, solanaConfigSource. igpOverheadAccount optional. + +### 1.3 Chains +| id | family | env | native | protocolDomains | +|---|---|---|---|---| +| aleo | aleo | mainnet | ALEO | xreserve 10002, hyperlane 1634493807 | +| ethereum | evm | mainnet | ETH | xreserve 0, hyperlane 1 | +| solana | solana | mainnet | SOL | hyperlane 1399811149 | +| base | evm | mainnet | ETH | — | +| hyperevm | evm | mainnet | HYPE | — | +| aleo-testnet | aleo | testnet | ALEO | xreserve 10002, hyperlane 1617853565 | +| sepolia | evm | testnet | ETH | hyperlane 11155111 | + +### 1.4 Assets +| id | symbol | dec | kind | locator | tokenId | privacy | +|---|---|---|---|---|---|---| +| aleo/aleo | ALEO | 6 | native | aleo-program credits.aleo | | | +| aleo/usdcx | USDCx | 6 | token | aleo-program usdcx_stablecoin.aleo | | arc22 usdcx_stablecoin.aleo | +| aleo/eth | ETH | 18 | token | aleo-program hyp_warp_token_eth_v2.aleo | aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8 | arc20 arc20_eth.aleo | +| aleo/wbtc | WBTC | 8 | token | aleo-program hyp_warp_token_wbtc_v2.aleo | aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf | arc20 arc20_wbtc.aleo | +| aleo/usdt | USDT | 6 | token | aleo-program hyp_warp_token_usdt_v2.aleo | aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y | arc20 arc20_usdt.aleo | +| aleo/sol | SOL | 9 | token | aleo-program hyp_warp_token_sol_v2.aleo | aleo1aa0zt0vg9uwknekpqeefkvad55swp7833wc5crp2prv0lm4djuxs5r7k6v | arc20 arc20_sol.aleo | +| aleo/usad | USAD | 6 | token | aleo-program usad_stablecoin.aleo | | | +| ethereum/usdc | USDC | 6 | token | evm 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | | | +| ethereum/eth | ETH | 18 | native | native ETH | | | +| ethereum/wbtc | WBTC | 8 | token | evm 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 | | | +| ethereum/usdt | USDT | 6 | token | evm 0xdAC17F958D2ee523a2206206994597C13D831ec7 | | | +| ethereum/aleo | ALEO | 6 | token | (none) | | | +| ethereum/usad | USAD | 6 | token | (none) | | | +| solana/sol | SOL | 9 | native | native SOL | | | +| solana/aleo, base/aleo, hyperevm/aleo | ALEO | 6 | token | (none) | | | +| aleo-testnet/usdcx | USDCx | 6 | token | aleo-program test_usdcx_stablecoin.aleo | | arc22 test_usdcx_stablecoin.aleo | +| sepolia/usdc | USDC | 6 | token | evm 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 | | | + +### 1.5 Routes (22 total). Route id = `${protocol}:${sourceAssetId}->${destinationAssetId}` +``` +XRESERVE_SOURCE = 'https://developers.circle.com/xreserve/references/supported-blockchains-and-domains' +HYPERLANE_REGISTRY_COMMIT = '2621c16f2db1ccb46643265c110dac5ca2c7c51a' +HYPERLANE_SOURCE = 'https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes' +``` + +**A. xReserve mainnet** `xreserve:ethereum/usdc->aleo/usdcx` + reverse, active, deploymentId 'xreserve-usdcx-aleo': +``` +xReserveContract 0x8888888199b2Df864bf678259607d6D5EBb4e3Ce ; sourceChainId 1 ; sourceDomain 0 +ethereumDestinationDomain 0 ; arcDestinationDomain 26 ; remoteDomain 10002 +remoteToken usdcx_stablecoin.aleo +remoteTokenBytes32 0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711 +minimumAmountAtomic '2000000' ; withdrawalFeeAtomic '2000000' ; maxFeeAtomic '100000' +bridgeProgram usdcx_bridge_v2.aleo ; wrapperProgram shielded_usdcx_wrapper.aleo +attestationBaseUrl https://xreserve-api.circle.com/v1/attestations +``` +**B. xReserve testnet** `xreserve:sepolia/usdc->aleo-testnet/usdcx` + reverse, active, 'xreserve-usdcx-aleo-testnet': +``` +xReserveContract 0x008888878f94C0d87defdf0B07f46B93C1934442 ; sourceChainId 11155111 ; sourceDomain 0 +ethereumDestinationDomain 0 ; arcDestinationDomain 26 ; remoteDomain 10002 +remoteToken test_usdcx_stablecoin.aleo +remoteTokenBytes32 0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401 +minimumAmountAtomic '2000000' ; withdrawalFeeAtomic '2000000' ; maxFeeAtomic '100000' +bridgeProgram test_usdcx_bridge_v2.aleo ; wrapperProgram shielded_usdcx_wrapper.aleo +attestationBaseUrl https://xreserve-api-testnet.circle.com/v1/attestations +``` +**ETHEREUM_HYPERLANE_COMMON** (every Ethereum<->Aleo Hyperlane route): +``` +sourceChainId 1 ; destinationDomain 1634493807 +mailboxAddress 0xc005dc82818d67AF737725bD4bf75435d065D239 +interchainGasPaymaster 0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611 +interchainSecurityModule 0x0000000000000000000000000000000000000000 +registryCommit 2621c16f2db1ccb46643265c110dac5ca2c7c51a +``` +**ALEO_MAILBOX_METADATA** (every Hyperlane route): +``` +aleoHookManagerProgram hyp_hook_manager.aleo ; aleoMailboxProgram hyp_mailbox.aleo ; aleoMailboxProgramEdition 0 +aleoMailboxLocalDomain 1634493807 ; aleoMailboxObservedNonce 170 ; aleoMailboxObservedProcessCount 291 +aleoMailboxDefaultIsm aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk +aleoMailboxDefaultHook aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74 (= IGP identity in gas-config key) +aleoMailboxRequiredHook aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82 +aleoMailboxDispatchProxy aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s +aleoMailboxOwner aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8 +aleoMailboxMetadataReviewedAt 2026-08-17 ; aleoMailboxStateVerified true +``` +**C. hyperlane:ethereum/eth->aleo/eth** active 'ETH/aleo': COMMON + routerAddress 0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A, routerType native, destinationRouter hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8 + MAILBOX. +**D. hyperlane:aleo/eth->ethereum/eth** active: +``` +aleoRouterProgram hyp_warp_token_eth_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 0 ; aleoTokenType '1' +aleoTokenOwner aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r +aleoIsm = aleoHook = aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc +aleoTokenId 133188123661477349522757068766864658505569365361420630212878794317749195359field +aleoLocalDecimals 18 ; aleoRemoteDecimals 18 +aleoRemoteRouterEvmAddress 0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A +aleoRemoteRouterRecipient [0u8 x12, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8] +aleoRemoteRouterGas '44000' +aleoAllowanceSpender0 aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74 ; Spender1..3 = aleo1qqq…3ljyzc ; Amount0..3 '0' +aleoRecipient '[0u128, 0u128]' (placeholder) ; aleoPlaceholderConfiguration false ; aleoWithdrawalReviewedAt 2026-08-26 +``` +**E. hyperlane:ethereum/wbtc->aleo/wbtc** active 'WBTC/aleo': COMMON + routerAddress 0x20CDC85778b732073F7EecEF3DF25c0d310f8772, routerType collateral, tokenAddress 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, destinationRouter hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf + MAILBOX. +**F. hyperlane:aleo/wbtc->ethereum/wbtc** active: +``` +aleoRouterProgram hyp_warp_token_wbtc_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 0 ; aleoTokenType '1' +aleoTokenOwner aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95 +aleoTokenId 1505227928464760254508513036497943623956572091841806589002910775534260084309field +aleoLocalDecimals 8 ; aleoRemoteDecimals 8 +aleoRemoteRouterEvmAddress 0x20CDC85778b732073F7EecEF3DF25c0d310f8772 +aleoRemoteRouterRecipient [0u8 x12, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8] +aleoRemoteRouterGas '68000' +``` +**G. hyperlane:ethereum/usdt->aleo/usdt** active 'USDT/aleo': COMMON + routerAddress 0x3C2064D78e4578E8F936E3db42aEF044E33FBF31, routerType collateral, tokenAddress 0xdAC17F958D2ee523a2206206994597C13D831ec7, destinationRouter hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y, **requiresApprovalReset true** + MAILBOX. +**H. hyperlane:aleo/usdt->ethereum/usdt** active: +``` +aleoRouterProgram hyp_warp_token_usdt_v2.aleo ; aleoDestinationDomain 1 ; aleoProgramEdition 1 ; aleoTokenType '1' +aleoTokenOwner aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z +aleoTokenId 8295938150000417034830036849466229528602563851235385582732969109393809606969field +aleoLocalDecimals 6 ; aleoRemoteDecimals 18 ; aleoScale '1000000000000' +aleoRemoteRouterEvmAddress 0x3C2064D78e4578E8F936E3db42aEF044E33FBF31 +aleoRemoteRouterRecipient [0u8 x12, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8] +aleoRemoteRouterGas '68000' +``` +**I. hyperlane:solana/sol->aleo/sol** active 'SOL/aleo': +``` +warpProgramAddress 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 +tokenPda JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW +nativeCollateralPda 8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC +dispatchAuthorityPda ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY +mailboxProgramAddress E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi +mailboxOutboxPda BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk +igpProgramAddress BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv +igpProgramDataPda 8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho +igpAccount JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M +igpOverheadAccount AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF +splNoopProgramAddress noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV +destinationDomain 1634493807 ; destinationGasAmount '464000' +registryCommit 418056e21734d26a7d14692e0ec5e902cc9e86bf ; solanaReviewedAt 2026-08-31 +``` +**J. hyperlane:aleo/sol->solana/sol** active: +``` +aleoRouterProgram hyp_warp_token_sol_v2.aleo ; aleoDestinationDomain 1399811149 ; aleoTokenType '1' +aleoTokenOwner aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45 +aleoTokenId 6148061383892805373029428966764338809222769879628268522058032128225601478383field +aleoLocalDecimals 9 ; aleoRemoteDecimals 9 +aleoRemoteRouterSolanaAddress 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 +aleoRemoteRouterRecipient [112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8] +aleoRemoteRouterGas '300000' +``` +**K–N.** metadata-required ALEO pairs ('ALEO/aleo'): aleo/aleo<->ethereum/aleo, <->solana/aleo, <->base/aleo, <->hyperevm/aleo (MAILBOX only). +**O/P.** hyperlane:ethereum/usad->aleo/usad and reverse: metadata-required ('USAD/aleo'); reverse uses placeholders (aleoPlaceholderConfiguration true) — execution refuses. +Placeholder scaffold: ALEO_PLACEHOLDER_ADDRESS aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n; aleoTokenType '0'; aleoTokenId '0field'; recipient zeros; gas '0'. +Testnet registry = only the two Sepolia<->aleo-testnet xReserve routes. No testnet Hyperlane route. + +## 2. LIFECYCLE ACTIONS + +### 2.1 prepare(registry, params) -> BridgePlan (pure) +Inputs {source:{chain,asset}, destination:{chain,asset}, bridgeProtocol?, amount, recipient, sender?, mintMode?}. Filters routes on exact asset pair, not disabled, optional protocol; 0 or >1 matches → error. mintMode default 'public'; non-public only for xreserve with aleo destination. Amount parsed with source decimals (>0) and re-parsed with destination decimals (precision). Recipient regex-checked. fees: []. +Steps: xReserve EVM→Aleo: source-approval(reversible) → source-deposit(IRREVERSIBLE) → deposit-attestation(protocol) → destination-mint(aleo-wallet iff private else protocol). xReserve Aleo→EVM: source-burn(IRREVERSIBLE) → withdrawal-attestation → destination-withdrawal → destination-confirmation. Hyperlane: optional source-approval (token on non-aleo source) → source-dispatch(IRREVERSIBLE) → message-delivery → destination-confirmation. + +### 2.2 quote → kinds evm-hyperlane | solana-hyperlane | aleo-hyperlane | evm-xreserve | aleo-xreserve. aleo-xreserve quote makes NO network call: amountOut = amount − withdrawalFee (source decimals); fee entry {kind:'protocol', estimated:false}; throws if amount <= fee. + +### 2.3 execute params {plan, pollingIntervalMs?, confirmationTimeoutMs?, onCheckpoint?, mode?, userRecord?, merkleProof?, privateFee?, gasPaymentMicrocredits?, privateMintSecretNonce?, onProgress?}. Aleo Hyperlane mode 'caller'|'signer'; Aleo xReserve mode 'private'|'public'|'public-as-signer'. Two checkpoint hooks: post-proving pre-broadcast (status SOURCE_SUBMISSION_PENDING, protocolState.preparedTransaction = serialized tx) and post-broadcast. Aleo-Hyperlane reads destination balance BEFORE submitting (destinationBalanceBeforeAtomic, expectedDestinationIncreaseAtomic) and re-quotes IGP right before proving unless pinned (hook asserts exact equality). + +### 2.4 getStatus({plan, receipt}) — guards receipt.protocol == plan.protocol && protocolState.routeId == plan.route.id; terminal (COMPLETED|FAILED|EXPIRED) returned untouched. Branches: +1. SOURCE_APPROVAL_PENDING+evm → tx receipt; reverted → FAILED; success → SOURCE_SUBMISSION_PENDING. +2. SOURCE_CONFIRMING+aleo → transactionStatus; accepted → DELIVERY_PENDING; rejected → FAILED. +3/4. SOURCE_CONFIRMING+hyperlane evm/solana → protocol getSourceStatus (extract messageId). +5. DELIVERY_PENDING+hyperlane+messageId+dest aleo|evm → readHyperlaneDelivery on dest Mailbox → COMPLETED. +6. DELIVERY_PENDING+hyperlane+source aleo → balance-diff fallback (current >= before + expected → COMPLETED). +7. other hyperlane → unchanged. 8. xReserve Aleo→EVM DELIVERY_PENDING → unchanged. 9. else → 'Status refresh is not implemented'. +10. xReserve EVM→Aleo: for ATTESTATION_PENDING|DELIVERY_PENDING|DESTINATION_ACTION_REQUIRED first read Aleo nullifier → COMPLETED if delivered. SOURCE_CONFIRMING → source status; ATTESTATION_PENDING → Circle getAttestation (pending → unchanged; complete+non-private → DELIVERY_PENDING + attestation; complete+private → DESTINATION_ACTION_REQUIRED + nextAction {kind:'xreserve-private-mint'}); DESTINATION_CONFIRMING → Aleo tx status → COMPLETED/FAILED. + +### 2.5 BridgeProgress.next +``` +SOURCE_SUBMISSION_PENDING -> 'resume' +DESTINATION_ACTION_REQUIRED -> 'complete' +COMPLETED -> 'done' +FAILED | EXPIRED -> 'failed' +else -> 'wait' +``` +Statuses: PREPARED, SOURCE_APPROVAL_PENDING, SOURCE_SUBMISSION_PENDING, SOURCE_CONFIRMING, ATTESTATION_PENDING, DESTINATION_ACTION_REQUIRED, DELIVERY_PENDING, DESTINATION_CONFIRMING, COMPLETED, FAILED, EXPIRED. + +### 2.6 wait({progress, until?, pollingIntervalMs=15000, timeoutMs=1200000, onUpdate?}) — always stops at SOURCE_SUBMISSION_PENDING, DESTINATION_ACTION_REQUIRED, COMPLETED, FAILED, EXPIRED ∪ until. Timeout → 'Bridge status polling timed out in state X'. + +### 2.7 recover({checkpoint}) — version must be 1; re-runs prepare(checkpoint.intent); checks route id and registryVersion. Aleo source: prepared-but-unbroadcast → next 'resume' with preparedTransaction; else requires transactionId → one getStatus from SOURCE_CONFIRMING. Solana: requires transactionId; validates (blockhash,lastValidBlockHeight) both-or-neither. EVM Hyperlane: recoverSourceCheckpoint (log scan). EVM xReserve: recoverSourceCheckpoint; submitted destination tx → DESTINATION_CONFIRMING; prepared destination tx preserved only if still DESTINATION_ACTION_REQUIRED. + +### 2.8 resume({progress, ...}) — requires next=='resume' and SOURCE_SUBMISSION_PENDING. Aleo: rebroadcast identical bytes; DuplicateTransaction = success. EVM: protocol execute with resume receipt (re-scans history before new dispatch). + +### 2.9 complete — xReserve private mint only; requires DESTINATION_ACTION_REQUIRED + nextAction xreserve-private-mint; re-validates payload/messageHash/attestation; rebroadcasts preparedDestinationTransaction if present else calls private_mint. + +### 2.10 createBridgeCheckpoint(plan, receipt) — ALLOWLIST (version 1): +``` +intent: { source, destination, bridgeProtocol, amount, recipient, sender?, mintMode? (only aleo-program dest) } +route: { id, registryVersion } +source?: { approvalTransactionIds?, transactionId?, hookData?, blockhash?, lastValidBlockHeight?, preparedTransaction?: {transactionId, serializedTransaction} } +destination?: { transactionId?, preparedTransaction? } +deliveryVerification?: { balanceBeforeAtomic, expectedIncreaseAtomic } +``` +EXCLUDED: private keys, record plaintext, secretNonce, attestation bodies, amountAtomic, maxFeeAtomic, remoteRecipientBytes32, messageHash, payload, nonce. + +### 2.11 shield / unshield — asset must be aleo family with privacy. amount → `${atomic}u128` (>0). +shield: ARC-22 → transfer_public_to_private(recipient, amount); ARC-20 → shield(amount). +unshield: ARC-22 → transfer_private_to_public(recipient, amount, record, merkleProof ?? EMPTY); ARC-20 → unshield(record, amount). +EMPTY_MERKLE_PROOF_PAIR = `[{ siblings: [0field x16], leaf_index: 1u32 }, { siblings: [0field x16], leaf_index: 1u32 }]` (16 siblings each, comma+space joined). + +### 2.12 Errors — single `BridgeError(message, {cause})`. No code taxonomy. + +## 3. PROTOCOL MECHANICS + +### 3.1 EVM Hyperlane deposit +ABI: quoteTransferRemote(uint32,bytes32,uint256) view returns ((address token,uint256 amount)[]); transferRemote(uint32,bytes32,uint256) payable returns (bytes32); event SentTransferRemote(uint32 indexed destination, bytes32 indexed recipient, uint256 amount); allowance/approve; event DispatchId(bytes32 indexed messageId). +Quote: assert eth_chainId == sourceChainId. recipientBytes32 = aleoAddressToBytes32(recipient). Call quoteTransferRemote; nativeValueAtomic = Σ quotes with token == 0x0. Native route: nativeValue >= amount; fee = nativeValue − amount; msg.value = nativeValue (carries ETH + fee). Collateral: tokenAmount = Σ quotes with token == tokenAddress (>= amount); fee = nativeValue; msg.value = nativeValue. +Execute: defaults poll 1000ms / timeout 120000ms. Fresh quote at last moment. Wallet addr must equal plan.sender. Approval (collateral): allowance < required → if allowance > 0 && requiresApprovalReset: approve(router, 0) first; then approve(router, required). Checkpoint each hash BEFORE polling. Dispatch: transferRemote(destinationDomain, recipientBytes32, amountAtomic) value=nativeValue to router. Checkpoint at SOURCE_CONFIRMING before polling. On success, messageId from DispatchId log → DELIVERY_PENDING, id = messageId ?? txHash. protocolState {routeId, approvalTxIds, sourceSender, recipientBytes32, destinationDomain, nativeValueAtomic, amountAtomic}. +Recovery: getLogs(router, fromBlock=highest approval block) decode SentTransferRemote matching destination/recipient/amount; tx from == sender && to == router; >1 match → error. + +### 3.2 EVM xReserve USDC deposit +ABI: depositToRemote(uint256 value, uint32 remoteDomain, bytes32 remoteRecipient, address localToken, uint256 maxFee, bytes hookData); event DepositedToRemote(address indexed localToken, uint256 value, address indexed localDepositor, bytes32 indexed remoteRecipient, uint32 remoteDomain, bytes32 remoteToken, uint256 maxFee, bytes hookData). +Quote: amount >= minimumAmountAtomic; hookData; remoteRecipientBytes32 = public/record → aleoAddressToBytes32(recipient); PRIVATE → aleoAddressToBytes32(programAddress(wrapperProgram)). balanceOf + allowance; approvalRequired = allowance < amount. +Execute: approve(xReserve, amount) if needed; depositToRemote(amount, remoteDomain, remoteRecipientBytes32, tokenAddress, maxFeeAtomic, hookData) — NO msg.value. Confirm: find DepositedToRemote log from xReserve contract, re-verify every field. logIndex → nonce = calculateXReserveDepositNonce(sourceDomain, txHash, logIndex); payload = buildXReserveDepositPayload(...); messageHash = keccak256(payload). Receipt id = messageHash, ATTESTATION_PENDING, protocolState += {sourceDomain, remoteDomain, depositLogIndex, nonce, payload, messageHash, bridgeProgram, wrapperProgram}. +Attestation: GET {attestationBaseUrl}/{messageHash}; 404 → pending; other non-ok → error. Body {attestation:{payload, messageHash, attestation}}; verify echoed hash and keccak256(payload) == messageHash. +Hook byte 0: 0 public, 1 record, 2 private. + +### 3.3 Aleo Hyperlane transfer_remote +Program = aleoRouterProgram; fn 'transfer_remote' (mode caller) | 'transfer_remote_as_signer' (mode signer). 7 inputs: +``` +0 "{ token_type: {T}u8, token_owner: {owner}, ism: {ism}, hook: {hook}, token_id: {tokenId}, local_decimals: {ld}u8, remote_decimals: {rd}u8 }" +1 "{ default_hook: {aleoMailboxDefaultHook}, required_hook: {aleoMailboxRequiredHook} }" +2 "{ domain: {dest}u32, recipient: {aleoRemoteRouterRecipient}, gas: {aleoRemoteRouterGas}u128 }" +3 "{dest}u32" +4 "[{limb0}u128, {limb1}u128]" (evm/solana address → limbs) +5 "{amountAtomic}u128" +6 "[{ spender: S0, amount: {gasPayment}u64 }, { spender: S1, amount: 0u64 }, { spender: S2, amount: 0u64 }, { spender: S3, amount: 0u64 }]" +``` +Refuses placeholder config, non-active route, missing gasPayment. Receipt SOURCE_CONFIRMING protocolState {routeId, sourceProgram, sourceFunction}. +IGP quote: read hyp_hook_manager.aleo mapping destination_gas_configs key `{ igp: {aleoMailboxDefaultHook}, destination: {domain}u32 }` → struct {gas_overhead, exchange_rate, gas_price}. gasLimit = aleoRemoteRouterGas (0 → 50000). payment = ((gasLimit + gasOverhead) * gasPrice * exchangeRate) // 10_000_000_000 ; 0 < payment <= 2^64-1. Vector: gas_overhead 159337, exchange_rate 402, gas_price 1000000000, gasLimit 44000 → 8174147. + +### 3.4 Aleo xReserve burn +ethereumDestinationDomain must be 0. amount > withdrawalFeeAtomic strictly. args amount "{atomic}u128", nativeDomain "0u32", nativeRecipient = xReserveHexToAleoBytes(evmAddressToXReserveBytes32(recipient), 32). +| mode | program | fn | inputs | +| private (default) | wrapperProgram shielded_usdcx_wrapper.aleo | private_burn | [userRecord, amount, nativeDomain, nativeRecipient, merkleProof] | +| public | bridgeProgram usdcx_bridge_v2.aleo | burn_public | [amount, nativeDomain, nativeRecipient] | +| public-as-signer | bridgeProgram | burn_public_as_signer | same | +Private: userRecord required (Token record of remoteToken program); merkleProof `[MerkleProof; 2]` literal required (no default). Receipt SOURCE_CONFIRMING; protocolState {routeId, burnMode, amountAtomic, nativeDomain, nativeRecipientBytes32, sourceProgram, sourceFunction, forwardingService:'aleo-burn-attestation'}. + +### 3.5 Aleo xReserve private mint +Preconditions: attestation complete; payload/messageHash match; keccak256(attestation.payload) == messageHash. Secret nonce check: expectedHookData = buildXReserveHookData('private', recipient, env, secretNonce); attestedHookData = '0x' + payload.slice(-130) (last 65 bytes); must match. secretNonce default '0scalar'. +Call wrapperProgram.private_mint with 5 inputs: [xReserveHexToAleoBytes(payload,305), xReserveHexToAleoBytes(attestation,65), xReserveHexToAleoBytes(messageHash,32), secretNonce, recipient]. Receipt DESTINATION_CONFIRMING. + +### 3.6 Solana Hyperlane SOL transfer_remote +Instruction data 77 bytes LE: [0..8) 01x8 discriminator; [8] 0x01 variant; [9..13) destination u32 LE; [13..45) recipient 32 bytes (aleoAddressToBytes32, no reversal); [45..77) amount u256 LE. +PDAs: dispatched-message = PDA(mailboxProgram, ['hyperlane','-','dispatched_message','-', uniqueMessagePubkey]); gas-payment = PDA(igpProgram, ['hyperlane_igp','-','gas_payment','-', uniqueMessagePubkey]). +Accounts (16; 15 without overhead): +``` +0 System 11111111111111111111111111111111 ro ; 1 splNoop ro ; 2 tokenPda ro ; 3 mailboxProgram ro ; 4 mailboxOutboxPda rw +5 dispatchAuthorityPda ro ; 6 sender signer rw ; 7 uniqueMessage signer ro ; 8 dispatchedMessagePda rw +9 igpProgram ro ; 10 igpProgramDataPda rw ; 11 gasPaymentPda rw ; 12 igpOverheadAccount ro (OPTIONAL, omitted if absent) +13 igpAccount rw ; 14 System ro ; 15 nativeCollateralPda rw +``` +IGP account decode: [1B initialized][8B "IGP_____"][1B bump][32B salt][1B owner Option tag (+32B)][32B beneficiary][4B count u32 LE]; entries 38B: [4B domain u32][1B oracle tag must be 0][16B token_exchange_rate u128][16B gas_price u128][1B token_decimals]. dest_cost = gasAmount*gas_price; origin_cost = dest_cost*exchange_rate // 10^19; lamports = origin_cost * 10^(9−dec) if dec<=9 else // 10^(dec−9). gasAmount = destinationGasAmount '464000'. Vector: exchange_rate 751705303136, gas_price 83169, dec 6 → 2_900_000 lamports. +Quote: IGP account data, throwaway unique signer, blockhash (confirmed), v0 message with compute unit limit 400_000; getFeeForMessage; rent for 141, 194, 0 bytes; total = amount + igp + fee + rent. plan.sender required. +Execute: wallet addr == plan.sender; re-quote; preflight balance; partial sign with unique-message keypair; wallet signs fee payer; send. Checkpoint SOURCE_CONFIRMING with {routeId, signature, uniqueMessageAddress, destinationDomain, quotedLamports, blockhash, lastValidBlockHeight} BEFORE polling. Poll getSignatureStatuses(searchTransactionHistory); failed → throw; confirmed|finalized → done; else isBlockhashValid → 'expired'; read exceptions swallowed. Message id: getTransaction logs (maxSupportedTransactionVersion 0, commitment confirmed), regex `Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})`; absence → messageIdUnavailable true. + +### 3.7 Delivery verification +- Hyperlane → Aleo: mapping `deliveries` on hyp_mailbox.aleo key `{ id: [{lo}u128, {hi}u128] }` with lo = LE u128 of bytes[0..16), hi = bytes[16..32). Non-null → delivered. Vector: 0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d → `{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }`. +- Aleo → EVM: Mailbox `delivered(bytes32) view returns (bool)`. +- Aleo → Solana: balance-diff fallback only. +- xReserve → Aleo: mapping `nullifier` on bridgeProgram key xReserveHexToAleoBytes(nonce, 32); value 'true' → delivered. +- Hyperlane explorer never used in production status. + +### 3.8 Encoders +aleoAddressToBytes32: 'aleo1' prefix, len 63, bech32m (const 0x2bc830a3, alphabet qpzry9x8gf2tvdw0s3jn54khce6mua7l, generators 3b6a57b2 26508e6d 1ea119fa 3d4233dd 2a1462b3), 32 bytes. Vector aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n → 0xb102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811. +bytes32ToAleoAddress: inverse. +evmAddressToXReserveBytes32: left-pad checksummed address to 32. +evmAddressToAleoHyperlaneRecipient: pad to 32, limbs LE u128 of [0..16),[16..32). Vector 0x1e196d0a7d8189054c4db744ab3340c3f1c68b19 → [13858749752514421660238621190289096704, 33956464229475118999063216025592496509]; 0x…0001 → [0, 1329227995784915872903807060280344576]. +solanaAddressToAleoHyperlaneRecipient: bs58 decode 32 bytes, same limbs. 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 → [127878782877948140186055645953777992816, 163261512394675613100746600600636171918]. +calculateXReserveDepositNonce = keccak256(abi.encode(uint32 sourceDomain) || txHash(32) || abi.encode(uint256 logIndex)). +buildXReserveDepositPayload (305 bytes): [0..8) 5a2e0acd00000001; [8..40) amount u256 BE; [40..44) remoteDomain u32 BE; [44..76) remoteToken; [76..108) remoteRecipient; [108..140) localToken padded; [140..172) depositor padded; [172..204) maxFee u256 BE; [204..236) nonce; [236..240) 0x00000041; [240..305) hookData 65. +buildXReserveHookData(mode, recipient, env, secretNonce='0scalar'): 65 bytes; byte0 = 0/1/2; private: bytes[1..33) = BHP256.commit(Plaintext(recipient).toBitsLe(), Scalar(secretNonce)).toBytesLe(). +xReserveHexToAleoBytes(hex, n): `[0u8,255u8]` NO spaces. +parseDecimalAmount / formatDecimalAmount: strict regex `^(\d+)(?:\.(\d+))?$`, frac digits <= decimals; format strips trailing zeros. + +## 4. TEST VECTORS — see test/utils/*.test.ts, test/solana/*.test.ts, test/actions/*.test.ts, fixtures sealevel-transfer-remote.json (mainnet tx cWFKiumuvVuvrxM8xtunZxNM4FNUppSdyNm7HEqKjV3ZmENebD4DAf44kbyvq9fKJ61VzNrH3tYpLJgUrY8MEGW, instruction base64 AQEBAQEBAQEBb2VsYRw0lpkefGEc7V7lzQze6WnFPvyKVJeuBQgZse8A7SkSACqpcJ0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, amount 676200000000, recipient aleo1rs6fdxg703s3em27uhxsehhfd8znaly22jt6upggrxc77q8d9yfq33pk28), sealevel-igp-account.json (JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M). Solana rent vectors 141→1_872_240, 194→2_241_120, 0→890_880; fee 10_000. +Live gates: BRIDGE_LIVE_FUNDS=1 + BRIDGE_LIVE_STATE_DIR; BRIDGE_LIVE_MAINNET_ACK=I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS + BRIDGE_LIVE_MAINNET_CASES; BRIDGE_LIVE_MAINNET_EXECUTE=I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS. Hyperlane minimum = one atomic unit; xReserve = 2 USDC. + +## 5. AGENT/MCP — three read-only tools: bridge_list_assets, bridge_list_routes, bridge_quote_transfer. No execute tool. + +## 6. EXTERNAL — Circle attestation mainnet/testnet URLs above; DEFAULT_SOLANA_RPC_URL https://api.mainnet-beta.solana.com; Hyperlane explorer GraphQL https://explorer4.hasura.app/v1/graphql (live tests only). + +## 7. INVARIANTS +1. Re-resolve route from live registry by id; refuse if plan.registryVersion != registry.version. +2. Checkpoint is an allowlist. +3. Idempotent Aleo rebroadcast (prove → persist bytes → broadcast; duplicate = success). +4. Exact integer arithmetic (hook payment, IGP) — on-chain equality asserts. +5. Timeout != failure. +6. getStatus checks destination nullifier first for inbound xReserve. +7. Private xReserve deposit recipient = wrapper program's address. +8. xReserveHexToAleoBytes has no spaces; struct/array literals elsewhere do. diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml new file mode 100644 index 00000000..820a7b48 --- /dev/null +++ b/bridge-sdk/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "aleo-bridge-sdk" +version = "0.1.0" +description = "Python SDK for bridging assets between Aleo, Ethereum and Solana over Hyperlane warp routes and Circle xReserve" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["aleo-sdk>=0.5.0", "requests>=2"] + +[project.optional-dependencies] +evm = ["web3>=7,<9", "eth-account>=0.13"] +solana = ["solders>=0.21", "solana>=0.35"] +# mcp 2.0 renamed the Server registration API — pin to 1.x (same rule as shield-swap). +mcp = ["mcp>=1.0,<2"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "web3>=7,<9", "eth-account>=0.13", + "solders>=0.21", "solana>=0.35", "mcp>=1.0,<2"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python/aleo_bridge"] diff --git a/bridge-sdk/pyrightconfig.json b/bridge-sdk/pyrightconfig.json new file mode 100644 index 00000000..973333f4 --- /dev/null +++ b/bridge-sdk/pyrightconfig.json @@ -0,0 +1,7 @@ +{ + "include": ["python/aleo_bridge"], + "extraPaths": ["python"], + "venvPath": ".", + "venv": ".venv", + "reportMissingModuleSource": false +} diff --git a/bridge-sdk/pytest.ini b/bridge-sdk/pytest.ini new file mode 100644 index 00000000..3204b8aa --- /dev/null +++ b/bridge-sdk/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +pythonpath = python +testpaths = tests +markers = + live: read-only tests against the REAL mainnet API (BRIDGE_LIVE_READS=1 and -m live) +addopts = -m "not live" +asyncio_mode = auto diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py new file mode 100644 index 00000000..c8485bc9 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -0,0 +1,24 @@ +"""aleo_bridge — move assets between Aleo, Ethereum and Solana (Hyperlane warp routes, Circle xReserve). + +Web3.py idioms: bind an ``aleo.Aleo`` facade to :class:`Bridge`; reads return values, Aleo writes +return an :class:`AleoCall` with ``simulate()`` / ``prove()`` / ``transact()`` / ``delegate()``. +Exports grow in Tasks 4–11 of plan 1; keep this list sorted. +""" +from __future__ import annotations + +__version__ = "0.1.0" + +from .errors import ( # noqa: E402 + AmbiguousRouteError, AttestationError, BridgeError, ChainMismatchError, CheckpointInvalidError, + ConfigurationError, DeliveryUnknownError, InsufficientBalanceError, InvalidAmountError, + InvalidRecipientError, MissingExtraError, NotResumableError, PollingTimeoutError, + RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, +) + +__all__ = [ + "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", + "CheckpointInvalidError", "ConfigurationError", "DeliveryUnknownError", "InsufficientBalanceError", + "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", + "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", + "UnsupportedRouteError", +] diff --git a/bridge-sdk/python/aleo_bridge/errors.py b/bridge-sdk/python/aleo_bridge/errors.py new file mode 100644 index 00000000..6252db0e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/errors.py @@ -0,0 +1,93 @@ +"""Error taxonomy — every failure raised by aleo_bridge is a BridgeError whose message states the remedy.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover + from .types import Progress, Status + + +class BridgeError(Exception): + """Base class for every error raised by aleo_bridge.""" + + +class ConfigurationError(BridgeError): + """The client, environment, or registry is configured inconsistently.""" + + +class MissingExtraError(BridgeError): + """An optional dependency group is required for this feature.""" + + def __init__(self, extra: str, feature: str) -> None: + self.extra = extra + self.feature = feature + super().__init__(f"{feature} requires the '{extra}' extra: pip install 'aleo-bridge-sdk[{extra}]'") + + +class RouteNotFoundError(BridgeError): + """No registry entry matches the lookup (route, asset, or chain).""" + + +class AmbiguousRouteError(BridgeError): + """More than one route matches; pass protocol= to disambiguate.""" + + +class RouteUnavailableError(BridgeError): + """The route exists but is metadata-required, disabled, or carries placeholder configuration.""" + + +class RegistryVersionMismatchError(BridgeError): + """A plan or checkpoint was prepared against a different registry version.""" + + +class UnsupportedRouteError(BridgeError): + """No implementation exists for this (protocol, chain family) combination or asset capability.""" + + +class InvalidAmountError(BridgeError): + """The amount is malformed, too precise, zero, or below the route minimum.""" + + +class InvalidRecipientError(BridgeError): + """The recipient does not match the destination chain's address format.""" + + +class InsufficientBalanceError(BridgeError): + """The account cannot cover the amount (public balance or no covering record).""" + + +class ChainMismatchError(BridgeError): + """The connected EVM chain id / Solana genesis does not match the route.""" + + +class NotResumableError(BridgeError): + """The progress is not in a resumable or completable state.""" + + +class CheckpointInvalidError(BridgeError): + """A checkpoint fails the version-1 allowlist or does not match its plan.""" + + +class AttestationError(BridgeError): + """Circle's response is malformed, does not hash to the requested message, or the secret does not open the commitment.""" + + +class DeliveryUnknownError(BridgeError): + """Delivery could not be determined from the destination chain.""" + + +class PollingTimeoutError(BridgeError): + """``wait`` gave up; carries the last observed status and progress (timeout is not failure).""" + + def __init__(self, message: str, *, status: "Status | str", progress: "Progress | None" = None) -> None: + self.status: Any = status + self.progress = progress + super().__init__(message) + + +__all__ = [ + "BridgeError", "ConfigurationError", "MissingExtraError", "RouteNotFoundError", "AmbiguousRouteError", + "RouteUnavailableError", "RegistryVersionMismatchError", "UnsupportedRouteError", "InvalidAmountError", + "InvalidRecipientError", "InsufficientBalanceError", "ChainMismatchError", "NotResumableError", + "CheckpointInvalidError", "AttestationError", "DeliveryUnknownError", "PollingTimeoutError", +] diff --git a/bridge-sdk/python/aleo_bridge/units.py b/bridge-sdk/python/aleo_bridge/units.py new file mode 100644 index 00000000..79e647a3 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/units.py @@ -0,0 +1,60 @@ +"""Exact decimal ↔ atomic conversion (port of veil ``utils/units.ts``). No floats anywhere.""" +from __future__ import annotations + +import re +from decimal import Decimal + +from .errors import InvalidAmountError + +_DECIMAL_RE = re.compile(r"^(\d+)(?:\.(\d+))?$") + + +def _check_decimals(decimals: int) -> None: + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise InvalidAmountError(f"Asset decimals must be a non-negative integer, got {decimals!r}") + + +def parse_decimal_amount(amount: "str | Decimal | int", decimals: int) -> int: + """``"0.5"`` with 6 decimals → ``500000``. Strict: digits with an optional fraction only. + + Rejects exponents, signs, a trailing dot, the empty string, floats, and any fraction longer + than *decimals* (that precision cannot exist on chain). + """ + _check_decimals(decimals) + if isinstance(amount, bool) or not isinstance(amount, (str, int, Decimal)): + raise InvalidAmountError(f"Amount must be a decimal string, int or Decimal, got {type(amount).__name__}") + text = format(amount, "f") if isinstance(amount, Decimal) else str(amount).strip() + match = _DECIMAL_RE.match(text) + if not match: + raise InvalidAmountError(f'Invalid decimal amount "{amount}" — use digits with an optional fraction, e.g. "0.5"') + whole, frac = match.group(1), match.group(2) or "" + if len(frac) > decimals: + raise InvalidAmountError( + f'Amount "{amount}" has {len(frac)} fractional digits but the asset supports {decimals}') + return int(whole + frac.ljust(decimals, "0")) + + +def format_decimal_amount(atomic: int, decimals: int) -> str: + """``2000001`` with 6 decimals → ``"2.000001"``; trailing fractional zeros are stripped.""" + _check_decimals(decimals) + if isinstance(atomic, bool) or not isinstance(atomic, int) or atomic < 0: + raise InvalidAmountError(f"Atomic amount must be a non-negative int, got {atomic!r}") + if decimals == 0: + return str(atomic) + digits = str(atomic).rjust(decimals + 1, "0") + whole, fraction = digits[:-decimals], digits[-decimals:].rstrip("0") + return f"{whole}.{fraction}" if fraction else whole + + +def resolve_amount(*, amount: "str | Decimal | int | None", amount_atomic: "int | None", decimals: int) -> int: + """Exactly one of *amount* (human) / *amount_atomic* (int) → atomic int.""" + if (amount is None) == (amount_atomic is None): + raise InvalidAmountError("Pass exactly one of amount= (decimal string) or amount_atomic= (int)") + if amount_atomic is not None: + if isinstance(amount_atomic, bool) or not isinstance(amount_atomic, int) or amount_atomic < 0: + raise InvalidAmountError(f"amount_atomic must be a non-negative int, got {amount_atomic!r}") + return amount_atomic + return parse_decimal_amount(amount, decimals) # type: ignore[arg-type] + + +__all__ = ["parse_decimal_amount", "format_decimal_amount", "resolve_amount"] diff --git a/bridge-sdk/tests/__init__.py b/bridge-sdk/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py new file mode 100644 index 00000000..13c0639b --- /dev/null +++ b/bridge-sdk/tests/test_package.py @@ -0,0 +1,39 @@ +import importlib +import sys + +import pytest + + +def test_import_without_optional_extras(monkeypatch): + for mod in ("web3", "eth_account", "solders", "solana", "mcp"): + monkeypatch.setitem(sys.modules, mod, None) # any import of these now raises ImportError + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + del sys.modules[name] + pkg = importlib.import_module("aleo_bridge") + assert pkg.__version__ == "0.1.0" + assert issubclass(pkg.RouteNotFoundError, pkg.BridgeError) + + +def test_error_hierarchy_and_messages(): + from aleo_bridge import errors as e + + for cls in (e.ConfigurationError, e.MissingExtraError, e.RouteNotFoundError, e.AmbiguousRouteError, + e.RouteUnavailableError, e.RegistryVersionMismatchError, e.UnsupportedRouteError, + e.InvalidAmountError, e.InvalidRecipientError, e.InsufficientBalanceError, + e.ChainMismatchError, e.NotResumableError, e.CheckpointInvalidError, e.AttestationError, + e.DeliveryUnknownError, e.PollingTimeoutError): + assert issubclass(cls, e.BridgeError) + err = e.MissingExtraError("evm", "Ethereum connections") + assert err.extra == "evm" + assert "pip install 'aleo-bridge-sdk[evm]'" in str(err) + + +def test_polling_timeout_carries_status(): + from aleo_bridge.errors import PollingTimeoutError + + err = PollingTimeoutError("Bridge status polling timed out in state DELIVERY_PENDING", + status="DELIVERY_PENDING", progress=None) + assert err.status == "DELIVERY_PENDING" and err.progress is None + with pytest.raises(PollingTimeoutError): + raise err diff --git a/bridge-sdk/tests/test_units.py b/bridge-sdk/tests/test_units.py new file mode 100644 index 00000000..7defa08b --- /dev/null +++ b/bridge-sdk/tests/test_units.py @@ -0,0 +1,54 @@ +from decimal import Decimal + +import pytest + +from aleo_bridge.errors import InvalidAmountError +from aleo_bridge.units import format_decimal_amount, parse_decimal_amount, resolve_amount + + +@pytest.mark.parametrize("amount,decimals,expected", [ + ("100", 6, 100_000_000), ("0.5", 6, 500_000), ("1.5", 6, 1_500_000), ("0.01", 8, 1_000_000), + ("1.000000000000000001", 18, 10**18 + 1), ("42", 0, 42), ("0.123456", 6, 123_456), + (Decimal("0.5"), 6, 500_000), (7, 6, 7_000_000), (" 2 ", 6, 2_000_000), +]) +def test_parse_decimal_amount(amount, decimals, expected): + assert parse_decimal_amount(amount, decimals) == expected + + +@pytest.mark.parametrize("amount", ["0.1234567", "1e6", "-1", "1.", "", ".5", "1,5", "abc"]) +def test_parse_decimal_amount_rejects(amount): + with pytest.raises(InvalidAmountError): + parse_decimal_amount(amount, 6) + + +def test_parse_decimal_amount_rejects_bad_types_and_decimals(): + with pytest.raises(InvalidAmountError): + parse_decimal_amount(1.5, 6) # type: ignore[arg-type] + with pytest.raises(InvalidAmountError): + parse_decimal_amount(True, 6) # type: ignore[arg-type] + with pytest.raises(InvalidAmountError): + parse_decimal_amount("1", -1) + + +def test_format_decimal_amount(): + assert format_decimal_amount(2_000_001, 6) == "2.000001" + assert format_decimal_amount(1_500_000, 6) == "1.5" + assert format_decimal_amount(100_000_000, 6) == "100" + assert format_decimal_amount(1, 18) == "0.000000000000000001" + assert format_decimal_amount(0, 6) == "0" + assert format_decimal_amount(42, 0) == "42" + with pytest.raises(InvalidAmountError): + format_decimal_amount(-1, 6) + + +def test_resolve_amount_exactly_one(): + assert resolve_amount(amount="0.001", amount_atomic=None, decimals=8) == 100_000 + assert resolve_amount(amount=None, amount_atomic=100_000, decimals=8) == 100_000 + with pytest.raises(InvalidAmountError, match="exactly one"): + resolve_amount(amount=None, amount_atomic=None, decimals=8) + with pytest.raises(InvalidAmountError, match="exactly one"): + resolve_amount(amount="1", amount_atomic=1, decimals=8) + with pytest.raises(InvalidAmountError): + resolve_amount(amount=None, amount_atomic=-5, decimals=8) + with pytest.raises(InvalidAmountError): + resolve_amount(amount=None, amount_atomic="5", decimals=8) # type: ignore[arg-type] From a4f8a38f98b818e468d382d3e275bc6dfc57ec30 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:13:21 -0400 Subject: [PATCH 02/94] feat(bridge-sdk): vendor keccak-256 and base58 primitives --- bridge-sdk/python/aleo_bridge/_base58.py | 35 +++++++++++++++ bridge-sdk/python/aleo_bridge/_keccak.py | 57 ++++++++++++++++++++++++ bridge-sdk/tests/test_base58.py | 29 ++++++++++++ bridge-sdk/tests/test_keccak.py | 22 +++++++++ 4 files changed, 143 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/_base58.py create mode 100644 bridge-sdk/python/aleo_bridge/_keccak.py create mode 100644 bridge-sdk/tests/test_base58.py create mode 100644 bridge-sdk/tests/test_keccak.py diff --git a/bridge-sdk/python/aleo_bridge/_base58.py b/bridge-sdk/python/aleo_bridge/_base58.py new file mode 100644 index 00000000..3d89212a --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_base58.py @@ -0,0 +1,35 @@ +"""Bitcoin/Solana base58 (no checksum). Vendored so Solana recipients encode without the solana extra.""" +from __future__ import annotations + +ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +_INDEX = {c: i for i, c in enumerate(ALPHABET)} + + +def b58decode(s: str) -> bytes: + """Decode *s*; leading ``1`` characters become leading zero bytes. ``ValueError`` on a bad character.""" + if not isinstance(s, str): + raise ValueError("base58 input must be a str") + n = 0 + for ch in s: + try: + n = n * 58 + _INDEX[ch] + except KeyError: + raise ValueError(f"Invalid base58 character {ch!r}") from None + body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" + pad = len(s) - len(s.lstrip("1")) + return bytes(pad) + body + + +def b58encode(data: bytes) -> str: + """Encode *data*; leading zero bytes become leading ``1`` characters.""" + data = bytes(data) + pad = len(data) - len(data.lstrip(b"\x00")) + n = int.from_bytes(data, "big") + out = [] + while n: + n, rem = divmod(n, 58) + out.append(ALPHABET[rem]) + return "1" * pad + "".join(reversed(out)) + + +__all__ = ["ALPHABET", "b58decode", "b58encode"] diff --git a/bridge-sdk/python/aleo_bridge/_keccak.py b/bridge-sdk/python/aleo_bridge/_keccak.py new file mode 100644 index 00000000..41bf2500 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_keccak.py @@ -0,0 +1,57 @@ +"""Pure-Python Keccak-256 (Ethereum's keccak; padding byte 0x01, NOT SHA3-256's 0x06). + +Vendored so an Aleo-only install can hash xReserve payloads, verify Circle attestations and +checksum EVM addresses without web3. When web3 is installed the two agree (tested). +""" +from __future__ import annotations + +_RC = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +] +_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]] +_MASK = (1 << 64) - 1 +_RATE = 136 # 1088-bit rate for a 256-bit digest + + +def _rol(x: int, n: int) -> int: + n %= 64 + return ((x << n) | (x >> (64 - n))) & _MASK if n else x + + +def _keccak_f(a: list[int]) -> None: + for rc in _RC: + c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)] + d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)] + for i in range(25): + a[i] ^= d[i % 5] + b = [0] * 25 + for x in range(5): + for y in range(5): + b[y + 5 * ((2 * x + 3 * y) % 5)] = _rol(a[x + 5 * y], _ROT[x][y]) + for x in range(5): + for y in range(5): + a[x + 5 * y] = b[x + 5 * y] ^ ((~b[(x + 1) % 5 + 5 * y]) & b[(x + 2) % 5 + 5 * y]) + a[0] ^= rc + + +def keccak256(data: "bytes | bytearray | memoryview") -> bytes: + """Keccak-256 digest of *data* (32 bytes).""" + state = [0] * 25 + msg = bytearray(data) + b"\x01" + msg += bytes((-len(msg)) % _RATE) + msg[-1] |= 0x80 + for off in range(0, len(msg), _RATE): + block = msg[off:off + _RATE] + for i in range(_RATE // 8): + state[i] ^= int.from_bytes(block[8 * i:8 * i + 8], "little") + _keccak_f(state) + return b"".join(state[i].to_bytes(8, "little") for i in range(4)) + + +__all__ = ["keccak256"] diff --git a/bridge-sdk/tests/test_base58.py b/bridge-sdk/tests/test_base58.py new file mode 100644 index 00000000..427336f4 --- /dev/null +++ b/bridge-sdk/tests/test_base58.py @@ -0,0 +1,29 @@ +import pytest + +from aleo_bridge._base58 import b58decode, b58encode + +SYSTEM_PROGRAM = "11111111111111111111111111111111" +WARP_PROGRAM = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + + +def test_decode_known_solana_keys(): + assert b58decode(SYSTEM_PROGRAM) == bytes(32) + raw = b58decode(WARP_PROGRAM) + assert len(raw) == 32 + # First four bytes match the aleoRemoteRouterRecipient literal of hyperlane:aleo/sol->solana/sol + assert list(raw[:4]) == [112, 4, 72, 22] + assert list(raw[-4:]) == [7, 6, 211, 122] + + +def test_round_trip_and_leading_zero_handling(): + for value in (bytes(32), b"\x00\x00\x01", b"\x01\x00\x00", bytes(range(1, 33)), b""): + assert b58decode(b58encode(value)) == value + assert b58encode(b"") == "" + assert b58encode(bytes(32)) == SYSTEM_PROGRAM + assert b58encode(b58decode(WARP_PROGRAM)) == WARP_PROGRAM + + +def test_decode_rejects_bad_characters(): + for bad in ("not-base58!", "0OIl", "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 "): + with pytest.raises(ValueError): + b58decode(bad) diff --git a/bridge-sdk/tests/test_keccak.py b/bridge-sdk/tests/test_keccak.py new file mode 100644 index 00000000..0a16a1c7 --- /dev/null +++ b/bridge-sdk/tests/test_keccak.py @@ -0,0 +1,22 @@ +import pytest + +from aleo_bridge._keccak import keccak256 + + +def test_keccak_known_answers(): + assert keccak256(b"").hex() == "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + assert keccak256(b"abc").hex() == "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45" + # Hyperlane Mailbox event topic — pinned by the whole ecosystem. + assert keccak256(b"DispatchId(bytes32)").hex() == \ + "788dbc1b7152732178210e7f4d9d010ef016f9eafbe66786bd7169f56e0c353a" + + +def test_keccak_accepts_bytearray_and_memoryview(): + assert keccak256(bytearray(b"abc")) == keccak256(b"abc") + assert keccak256(memoryview(b"abc")) == keccak256(b"abc") + + +def test_keccak_long_input_spans_blocks_and_agrees_with_web3(): + web3 = pytest.importorskip("web3") # dev extra; the primitive still ships without it + for data in (bytes(range(256)) * 3, bytes(135), bytes(136), bytes(137), b"x" * 1000): + assert keccak256(data) == bytes(web3.Web3.keccak(data)) From 3d68bb47d5d21ea8f8a718dc5bc460d5192f372c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:20:31 -0400 Subject: [PATCH 03/94] =?UTF-8?q?feat(bridge-sdk):=20wire=20encoders=20?= =?UTF-8?q?=E2=80=94=20bech32m,=20hyperlane=20limbs,=20xReserve=20payload?= =?UTF-8?q?=20and=20hook=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bridge-sdk/python/aleo_bridge/encoding.py | 288 ++++++++++++++++++++++ bridge-sdk/tests/test_encoding.py | 207 ++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/encoding.py create mode 100644 bridge-sdk/tests/test_encoding.py diff --git a/bridge-sdk/python/aleo_bridge/encoding.py b/bridge-sdk/python/aleo_bridge/encoding.py new file mode 100644 index 00000000..63764b2e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/encoding.py @@ -0,0 +1,288 @@ +"""Pure wire encoders shared by every route family (port of veil utils/xreserve.ts, hyperlane.ts, +hyperlaneDelivery.ts). Nothing here touches the network; only ``xreserve_hook_data`` (private mode) +and ``aleo_program_address`` load the ``aleo.`` bindings. + +Spacing rules (invariant 8): ``u8_array_literal`` joins with ``","`` and NO space, every other +struct/array literal joins with ``", "``. +""" +from __future__ import annotations + +import re +from typing import Any + +from ._base58 import b58decode +from ._keccak import keccak256 +from .errors import AttestationError, ConfigurationError, InvalidRecipientError + +BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32M_CONST = 0x2BC830A3 +_BECH32_GENERATORS = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) +_HRP = "aleo" +_EVM_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") +_SCALAR_RE = re.compile(r"^(0|[1-9][0-9]*)scalar$") +NETWORKS = ("mainnet", "testnet") +MINT_MODES = ("public", "record", "private") +HOOK_DATA_BYTES = 65 +XRESERVE_PAYLOAD_BYTES = 305 +_PAYLOAD_HEADER = bytes.fromhex("5a2e0acd00000001") +_HOOK_LENGTH_FIELD = bytes.fromhex("00000041") + + +# ── Generic byte helpers ────────────────────────────────────────────────────── + +def hex_to_bytes(value: "str | bytes | bytearray | memoryview", expected_len: "int | None" = None) -> bytes: + """``0x``-prefixed (or bare) hex, or bytes, to ``bytes``; optionally assert the width.""" + if isinstance(value, str): + text = value[2:] if value[:2] in ("0x", "0X") else value + try: + data = bytes.fromhex(text) + except ValueError as exc: + raise ValueError(f"Not hexadecimal: {value!r}") from exc + else: + data = bytes(value) + if expected_len is not None and len(data) != expected_len: + raise ValueError(f"Expected {expected_len} bytes, got {len(data)}") + return data + + +def to_hex(data: "bytes | bytearray | memoryview") -> str: + return "0x" + bytes(data).hex() + + +def network_module(network: str) -> Any: + """``aleo.mainnet`` / ``aleo.testnet`` for *network*; ``ConfigurationError`` otherwise.""" + if network not in NETWORKS: + raise ConfigurationError(f"network must be one of {NETWORKS}, got {network!r}") + import aleo # noqa: WPS433 — the bindings are a runtime dependency, imported lazily + return getattr(aleo, network) + + +def field_bytes_le(value: Any) -> bytes: + """``Field/Address.to_bytes_le()`` returns ``list[int]`` at runtime; normalise to bytes.""" + return bytes(value.to_bytes_le()) + + +def validate_scalar(secret_nonce: str) -> str: + if not isinstance(secret_nonce, str) or not _SCALAR_RE.match(secret_nonce): + raise ConfigurationError( + f"secret_nonce must be a non-negative Aleo scalar literal such as 0scalar, got {secret_nonce!r}") + return secret_nonce + + +# ── Aleo bech32m ────────────────────────────────────────────────────────────── + +def _bech32_polymod(values: list[int]) -> int: + chk = 1 + for v in values: + top = chk >> 25 + chk = ((chk & 0x1FFFFFF) << 5) ^ v + for i in range(5): + if (top >> i) & 1: + chk ^= _BECH32_GENERATORS[i] + return chk + + +def _hrp_expand() -> list[int]: + return [ord(c) >> 5 for c in _HRP] + [0] + [ord(c) & 31 for c in _HRP] + + +def aleo_address_to_bytes32(address: str) -> bytes: + """Decode a checksummed ``aleo1…`` bech32m address into its 32 payload bytes (xReserve/Hyperlane form).""" + try: + if not isinstance(address, str) or not address.startswith("aleo1") or len(address) != 63: + raise ValueError("invalid prefix or length") + words = [BECH32_ALPHABET.index(c) for c in address[5:]] # ValueError on a foreign character + if _bech32_polymod(_hrp_expand() + words) != BECH32M_CONST: + raise ValueError("invalid checksum") + acc = bits = 0 + out = bytearray() + for word in words[:-6]: + acc = (acc << 5) | word + bits += 5 + while bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + if bits >= 5 or ((acc << (8 - bits)) & 0xFF) != 0: + raise ValueError("invalid padding") + if len(out) != 32: + raise ValueError("invalid payload") + return bytes(out) + except ValueError as exc: + raise InvalidRecipientError(f"Invalid Aleo recipient address: {address}") from exc + + +def bytes32_to_aleo_address(data: bytes) -> str: + """Inverse of :func:`aleo_address_to_bytes32` — re-encode 32 bytes as a checksummed ``aleo1…`` address.""" + raw = bytes(data) + if len(raw) != 32: + raise InvalidRecipientError(f"Invalid 32-byte Aleo recipient: {to_hex(raw)}") + acc = bits = 0 + words: list[int] = [] + for byte in raw: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + words.append((acc >> bits) & 31) + if bits: + words.append((acc << (5 - bits)) & 31) + checksum = _bech32_polymod(_hrp_expand() + words + [0] * 6) ^ BECH32M_CONST + words += [(checksum >> (5 * (5 - i))) & 31 for i in range(6)] + return _HRP + "1" + "".join(BECH32_ALPHABET[w] for w in words) + + +# ── EVM addresses ───────────────────────────────────────────────────────────── + +def to_checksum_address(address: str) -> str: + """EIP-55 checksum form of a 20-byte hex address.""" + body = address[2:].lower() + digest = keccak256(body.encode()).hex() + return "0x" + "".join(c.upper() if int(digest[i], 16) >= 8 else c for i, c in enumerate(body)) + + +def is_evm_address(value: Any) -> bool: + """20-byte hex; mixed case must be a valid EIP-55 checksum (viem ``isAddress`` semantics).""" + if not isinstance(value, str) or not _EVM_ADDRESS_RE.match(value): + return False + body = value[2:] + if body == body.lower() or body == body.upper(): + return True + return to_checksum_address(value) == value + + +def evm_address_to_bytes32(address: str) -> bytes: + """Left-pad a checksum-validated EVM address to 32 bytes (xReserve burn recipient / Hyperlane bytes32).""" + if not is_evm_address(address): + raise InvalidRecipientError(f"Invalid Ethereum recipient address: {address}") + return bytes(12) + bytes.fromhex(address[2:]) + + +# ── Hyperlane recipient limbs and Aleo literals ─────────────────────────────── + +def bytes32_to_u128_limbs(data: bytes) -> tuple[int, int]: + """Two little-endian u128 limbs over ``bytes[0:16]`` and ``bytes[16:32]``.""" + raw = bytes(data) + if len(raw) != 32: + raise ValueError(f"Hyperlane recipient limbs need exactly 32 bytes, got {len(raw)}") + return int.from_bytes(raw[:16], "little"), int.from_bytes(raw[16:], "little") + + +def evm_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: + if not is_evm_address(address): + raise InvalidRecipientError(f"Invalid Ethereum Hyperlane recipient: {address}") + return bytes32_to_u128_limbs(evm_address_to_bytes32(address)) + + +def solana_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: + try: + raw = b58decode(address) + if len(raw) != 32: + raise ValueError("invalid public key width") + return bytes32_to_u128_limbs(raw) + except ValueError as exc: + raise InvalidRecipientError(f"Invalid Solana Hyperlane recipient: {address}") from exc + + +def u128_pair_literal(limbs: tuple[int, int]) -> str: + """``[{lo}u128, {hi}u128]`` — the Warp Route recipient input (space after the comma).""" + lo, hi = limbs + return f"[{lo}u128, {hi}u128]" + + +def u8_array_literal(data: bytes) -> str: + """``[0u8,255u8]`` — veil ``xReserveHexToAleoBytes``: NO space after the comma.""" + return "[" + ",".join(f"{b}u8" for b in bytes(data)) + "]" + + +def hyperlane_delivery_key(message_id: bytes) -> str: + """``hyp_mailbox.aleo/deliveries`` key: ``{ id: [{lo}u128, {hi}u128] }``.""" + lo, hi = bytes32_to_u128_limbs(hex_to_bytes(message_id, 32)) + return f"{{ id: [{lo}u128, {hi}u128] }}" + + +# ── Circle xReserve ─────────────────────────────────────────────────────────── + +def _uint_be(value: int, width: int) -> bytes: + if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value >= 1 << (8 * width): + raise ValueError(f"Unsigned value does not fit in {width} bytes: {value!r}") + return value.to_bytes(width, "big") + + +def xreserve_deposit_nonce(source_domain: int, tx_hash: "bytes | str", log_index: int) -> bytes: + """Circle's deposit nonce: ``keccak(abi.encode(uint32 domain) ‖ txHash ‖ abi.encode(uint256 logIndex))``.""" + return keccak256(_uint_be(source_domain, 32) + hex_to_bytes(tx_hash, 32) + _uint_be(log_index, 32)) + + +def xreserve_deposit_payload(*, amount: int, remote_domain: int, remote_token: bytes, remote_recipient: bytes, + local_token: str, depositor: str, max_fee: int, nonce: bytes, + hook_data: bytes) -> bytes: + """The canonical 305-byte xReserve v2 deposit payload Circle signs. + + header[0..8) amount[8..40) domain[40..44) remoteToken[44..76) recipient[76..108) + localToken[108..140) depositor[140..172) maxFee[172..204) nonce[204..236) hookLen[236..240) hook[240..305) + """ + for name, value, width in (("remote_token", remote_token, 32), ("remote_recipient", remote_recipient, 32), + ("nonce", nonce, 32), ("hook_data", hook_data, HOOK_DATA_BYTES)): + if len(bytes(value)) != width: + raise ValueError(f"{name} must contain {width} bytes") + out = bytearray(XRESERVE_PAYLOAD_BYTES) + out[0:8] = _PAYLOAD_HEADER + out[8:40] = _uint_be(amount, 32) + out[40:44] = _uint_be(remote_domain, 4) + out[44:76] = bytes(remote_token) + out[76:108] = bytes(remote_recipient) + out[108:140] = evm_address_to_bytes32(local_token) + out[140:172] = evm_address_to_bytes32(depositor) + out[172:204] = _uint_be(max_fee, 32) + out[204:236] = bytes(nonce) + out[236:240] = _HOOK_LENGTH_FIELD + out[240:305] = bytes(hook_data) + return bytes(out) + + +def xreserve_message_hash(payload: bytes) -> bytes: + """Circle's attestation lookup key: ``keccak256(payload)``.""" + return keccak256(bytes(payload)) + + +def xreserve_nonce_from_payload(payload: bytes) -> bytes: + """Deposit nonce (bytes 204..236) from a canonical payload; validates header, width and hook length.""" + raw = bytes(payload) + if len(raw) != XRESERVE_PAYLOAD_BYTES or raw[0:8] != _PAYLOAD_HEADER or raw[236:240] != _HOOK_LENGTH_FIELD: + raise AttestationError("xReserve payload has an invalid deposit layout") + return raw[204:236] + + +def xreserve_hook_data(mode: str, recipient: str, network: str, secret_nonce: str = "0scalar") -> bytes: + """65-byte xReserve hook: byte 0 selects the mint transition (0 public, 1 record, 2 private); + private mode carries ``BHP256.commit(bits(recipient), secret_nonce)`` in bytes 1..33.""" + if mode not in MINT_MODES: + raise ConfigurationError(f"Unsupported mint mode {mode!r}; expected one of {MINT_MODES}") + aleo_address_to_bytes32(recipient) # InvalidRecipientError before any FFI work + out = bytearray(HOOK_DATA_BYTES) + out[0] = MINT_MODES.index(mode) + if mode == "private": + validate_scalar(secret_nonce) + net = network_module(network) + bits = net.Plaintext.from_string(recipient).to_bits_le() + commitment = field_bytes_le(net.BHP256().commit(bits, net.Scalar.from_string(secret_nonce))) + if len(commitment) != 32: + raise ConfigurationError("Private mint commitment must contain 32 bytes") + out[1:33] = commitment + return bytes(out) + + +def aleo_program_address(program_id: str, network: str) -> str: + """The ``aleo1…`` account owned by a deployed program (private xReserve deposits target the wrapper's).""" + return str(network_module(network).Address.from_program_id(program_id)) + + +__all__ = [ + "BECH32_ALPHABET", "HOOK_DATA_BYTES", "MINT_MODES", "NETWORKS", "XRESERVE_PAYLOAD_BYTES", + "aleo_address_to_bytes32", "aleo_program_address", "bytes32_to_aleo_address", "bytes32_to_u128_limbs", + "evm_address_to_bytes32", "evm_address_to_hyperlane_recipient", "field_bytes_le", "hex_to_bytes", + "hyperlane_delivery_key", "is_evm_address", "network_module", "solana_address_to_hyperlane_recipient", + "to_checksum_address", "to_hex", "u128_pair_literal", "u8_array_literal", "validate_scalar", + "xreserve_deposit_nonce", "xreserve_deposit_payload", "xreserve_hook_data", "xreserve_message_hash", + "xreserve_nonce_from_payload", +] diff --git a/bridge-sdk/tests/test_encoding.py b/bridge-sdk/tests/test_encoding.py new file mode 100644 index 00000000..60b2b1ca --- /dev/null +++ b/bridge-sdk/tests/test_encoding.py @@ -0,0 +1,207 @@ +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.errors import AttestationError, ConfigurationError, InvalidRecipientError + +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +RECIPIENT_BYTES32 = "b102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +EVM1 = "0x0000000000000000000000000000000000000001" +WRAPPER = "shielded_usdcx_wrapper.aleo" +MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" + + +# ── bech32m (veil test/utils/xreserve.test.ts) ──────────────────────────────── + +def test_aleo_address_round_trip_and_checksum(): + assert enc.aleo_address_to_bytes32(RECIPIENT).hex() == RECIPIENT_BYTES32 + assert enc.bytes32_to_aleo_address(bytes.fromhex(RECIPIENT_BYTES32)) == RECIPIENT + with pytest.raises(InvalidRecipientError, match="Invalid Aleo recipient"): + enc.aleo_address_to_bytes32(RECIPIENT[:-1] + "q") + with pytest.raises(InvalidRecipientError): + enc.aleo_address_to_bytes32("aleo1short") + with pytest.raises(InvalidRecipientError): + enc.aleo_address_to_bytes32("aleo1" + "b" * 58) # 'b' is not in the alphabet + with pytest.raises(InvalidRecipientError, match="32-byte Aleo recipient"): + enc.bytes32_to_aleo_address(b"\x01") + + +def test_bech32_decoder_agrees_with_the_bindings(): + # Address.from_string(...).to_bits_le() is a 253-bit field-element encoding (no fixed byte + # width) and Plaintext.from_string(...).to_bits_le() prefixes a literal-type discriminant + # (279 bits total here) — neither packs cleanly into 32 bytes. to_bytes_le() is the API that + # actually returns the 32-byte payload, so that's what the bech32m payload is checked against. + from aleo import mainnet as net + packed = bytes(net.Address.from_string(RECIPIENT).to_bytes_le()) + assert len(packed) == 32 + assert enc.aleo_address_to_bytes32(RECIPIENT) == packed + + +# ── EVM addresses ───────────────────────────────────────────────────────────── + +def test_evm_address_to_bytes32_left_pads_and_checks_eip55(): + assert enc.evm_address_to_bytes32(EVM1).hex() == "00" * 31 + "01" + assert enc.evm_address_to_bytes32("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238").hex() == \ + "0000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c7238" + assert enc.is_evm_address("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238") # all-lowercase is fine + assert enc.to_checksum_address("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238") == \ + "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + with pytest.raises(InvalidRecipientError, match="Invalid Ethereum recipient"): + enc.evm_address_to_bytes32("0x1234") + with pytest.raises(InvalidRecipientError): # mixed case with a wrong checksum + enc.evm_address_to_bytes32("0x1C7D4B196Cb0C7B01d743Fbc6116a902379C7238") + + +# ── Hyperlane limbs (veil test/utils/hyperlane.test.ts) ─────────────────────── + +def test_evm_limbs(): + assert enc.evm_address_to_hyperlane_recipient("0x1e196d0a7d8189054c4db744ab3340c3f1c68b19") == ( + 13858749752514421660238621190289096704, 33956464229475118999063216025592496509) + assert enc.evm_address_to_hyperlane_recipient(EVM1) == (0, 1329227995784915872903807060280344576) + for bad in ("0x1234", "0x" + "11" * 32): + with pytest.raises(InvalidRecipientError, match="Invalid Ethereum Hyperlane recipient"): + enc.evm_address_to_hyperlane_recipient(bad) + + +def test_solana_limbs(): + assert enc.solana_address_to_hyperlane_recipient("8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7") == ( + 127878782877948140186055645953777992816, 163261512394675613100746600600636171918) + assert enc.solana_address_to_hyperlane_recipient("11111111111111111111111111111111") == (0, 0) + for bad in ("not-base58!", "1111"): + with pytest.raises(InvalidRecipientError, match="Invalid Solana Hyperlane recipient"): + enc.solana_address_to_hyperlane_recipient(bad) + + +def test_limbs_and_literals(): + assert enc.bytes32_to_u128_limbs(bytes(31) + b"\x01") == (0, 1 << 120) + with pytest.raises(ValueError): + enc.bytes32_to_u128_limbs(bytes(31)) + assert enc.u128_pair_literal((0, 1329227995784915872903807060280344576)) == \ + "[0u128, 1329227995784915872903807060280344576u128]" + assert enc.u8_array_literal(bytes.fromhex("00ff")) == "[0u8,255u8]" + assert enc.u8_array_literal(bytes(31) + b"\x01") == "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" + assert enc.hex_to_bytes("0x00ff", 2) == b"\x00\xff" + assert enc.hex_to_bytes(b"\x00\xff") == b"\x00\xff" + with pytest.raises(ValueError, match="32 bytes"): + enc.hex_to_bytes("0x00ff", 32) + assert enc.to_hex(b"\x00\xff") == "0x00ff" + + +def test_hyperlane_delivery_key_vector(): + # brief §3.7 vector + key = enc.hyperlane_delivery_key(enc.hex_to_bytes(MESSAGE_ID, 32)) + assert key == "{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }" + with pytest.raises(ValueError): + enc.hyperlane_delivery_key(b"\x00") + + +# ── xReserve wire format (veil test/utils/xreserve.test.ts) ─────────────────── + +def _payload(hook: bytes) -> tuple[bytes, bytes]: + nonce = enc.xreserve_deposit_nonce(0, bytes.fromhex("12" * 32), 4) + payload = enc.xreserve_deposit_payload( + amount=1_000_000, remote_domain=10002, + remote_token=bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401"), + remote_recipient=enc.aleo_address_to_bytes32(RECIPIENT), + local_token="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", depositor=EVM1, + max_fee=100_000, nonce=nonce, hook_data=hook) + return nonce, payload + + +def test_deposit_nonce_payload_and_hash_layout(): + nonce, payload = _payload(enc.xreserve_hook_data("public", RECIPIENT, "testnet")) + assert len(nonce) == 32 and len(payload) == 305 + assert payload[0:8].hex() == "5a2e0acd00000001" + assert payload[8:40] == (1_000_000).to_bytes(32, "big") + assert payload[40:44] == (10002).to_bytes(4, "big") + assert payload[44:76].hex() == "b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401" + assert payload[76:108].hex() == RECIPIENT_BYTES32 + assert payload[108:140].hex() == "0000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c7238" + assert payload[140:172].hex() == "00" * 31 + "01" + assert payload[172:204] == (100_000).to_bytes(32, "big") + assert payload[204:236] == nonce + assert payload[236:240].hex() == "00000041" + assert payload[240:305] == bytes(65) + assert len(enc.xreserve_message_hash(payload)) == 32 + assert enc.xreserve_nonce_from_payload(payload) == nonce + + +def test_deposit_nonce_matches_abi_encoding_via_web3(): + web3 = pytest.importorskip("web3") + from eth_abi import encode + tx_hash = bytes.fromhex("12" * 32) + expected = bytes(web3.Web3.keccak(encode(["uint32"], [0]) + tx_hash + encode(["uint256"], [4]))) + assert enc.xreserve_deposit_nonce(0, tx_hash, 4) == expected + assert enc.xreserve_deposit_nonce(0, "0x" + "12" * 32, 4) == expected # hex accepted too + + +def test_deposit_payload_rejects_bad_widths(): + good = dict(amount=1, remote_domain=1, remote_token=bytes(32), remote_recipient=bytes(32), + local_token=EVM1, depositor=EVM1, max_fee=0, nonce=bytes(32), hook_data=bytes(65)) + with pytest.raises(ValueError, match="remote_token must contain 32 bytes"): + enc.xreserve_deposit_payload(**{**good, "remote_token": bytes(31)}) + with pytest.raises(ValueError, match="hook_data must contain 65 bytes"): + enc.xreserve_deposit_payload(**{**good, "hook_data": bytes(64)}) + with pytest.raises(ValueError, match="does not fit"): + enc.xreserve_deposit_payload(**{**good, "remote_domain": 1 << 32}) + with pytest.raises(InvalidRecipientError): + enc.xreserve_deposit_payload(**{**good, "depositor": "0x1234"}) + + +def test_nonce_from_payload_rejects_bad_layout(): + with pytest.raises(AttestationError, match="invalid deposit layout"): + enc.xreserve_nonce_from_payload(bytes(305)) + with pytest.raises(AttestationError): + enc.xreserve_nonce_from_payload(bytes(304)) + + +# ── hook data (BHP256 through the real bindings) ────────────────────────────── + +def test_hook_data_public_and_record_are_pure(): + assert enc.xreserve_hook_data("public", RECIPIENT, "testnet") == bytes(65) + assert enc.xreserve_hook_data("record", RECIPIENT, "testnet") == b"\x01" + bytes(64) + + +def test_hook_data_private_commits_recipient_with_selected_scalar(): + default = enc.xreserve_hook_data("private", RECIPIENT, "testnet") + assert len(default) == 65 and default[0] == 2 and default[33:] == bytes(32) + assert default == enc.xreserve_hook_data("private", RECIPIENT, "testnet", "0scalar") + custom = enc.xreserve_hook_data("private", RECIPIENT, "testnet", "7scalar") + assert custom[0] == 2 and custom != default + # BHP256 is curve-level, not network-level: mainnet and testnet bindings agree. + assert enc.xreserve_hook_data("private", RECIPIENT, "mainnet") == default + # Independent recomputation through the bindings. + from aleo import testnet as net + commitment = net.BHP256().commit(net.Plaintext.from_string(RECIPIENT).to_bits_le(), + net.Scalar.from_string("7scalar")) + assert custom[1:33] == bytes(commitment.to_bytes_le()) + + +def test_hook_data_private_pinned_vector(): + # Pinned from the bindings on 2026-09-03 (RECIPIENT, 0scalar). If this fails while the previous + # test passes, the bindings' BHP256 output changed — investigate, do not re-pin blindly. + assert enc.xreserve_hook_data("private", RECIPIENT, "mainnet")[1:33].hex() == \ + "dd46b467d619a9628e58a71ebf24873c777f93ae2b7ac5dee3db7282ecef8d10" + + +def test_hook_data_validation(): + with pytest.raises(ConfigurationError, match="mint mode"): + enc.xreserve_hook_data("shielded", RECIPIENT, "mainnet") + with pytest.raises(ConfigurationError, match="scalar"): + enc.xreserve_hook_data("private", RECIPIENT, "mainnet", "not-a-scalar") + with pytest.raises(ConfigurationError, match="network"): + enc.xreserve_hook_data("private", RECIPIENT, "devnet") + with pytest.raises(InvalidRecipientError): + enc.xreserve_hook_data("private", "aleo1short", "mainnet") + + +# ── program address ─────────────────────────────────────────────────────────── + +def test_program_address_is_a_valid_address_and_matches_bindings(): + from aleo import mainnet as net + addr = enc.aleo_program_address(WRAPPER, "mainnet") + assert addr == str(net.Address.from_program_id(WRAPPER)) + assert len(enc.aleo_address_to_bytes32(addr)) == 32 + # Pinned 2026-09-03 from the bindings; see note on the hook-data pin above. + assert addr == "aleo183r3zgsr57fwtgk5duzeq9kqdkpmmtfj4k5469ddvm3tcfhhls9szktw82" + with pytest.raises(ConfigurationError, match="network"): + enc.aleo_program_address(WRAPPER, "devnet") From fb78ab873bcb7b112617a80e268e49d54b813723 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:33:52 -0400 Subject: [PATCH 04/94] feat(bridge-sdk): pinned veil registry (7 chains, 19 assets, 22 routes) with typed views and validation --- bridge-sdk/python/aleo_bridge/__init__.py | 2 + .../python/aleo_bridge/_registry_data.py | 386 ++++++++++++++++++ bridge-sdk/python/aleo_bridge/registry.py | 290 +++++++++++++ bridge-sdk/tests/test_registry.py | 322 +++++++++++++++ 4 files changed, 1000 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/_registry_data.py create mode 100644 bridge-sdk/python/aleo_bridge/registry.py create mode 100644 bridge-sdk/tests/test_registry.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index c8485bc9..e74a5d23 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -14,6 +14,7 @@ InvalidRecipientError, MissingExtraError, NotResumableError, PollingTimeoutError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, ) +from .registry import DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, validate_registry # noqa: E402 __all__ = [ "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", @@ -21,4 +22,5 @@ "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", "UnsupportedRouteError", + "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", ] diff --git a/bridge-sdk/python/aleo_bridge/_registry_data.py b/bridge-sdk/python/aleo_bridge/_registry_data.py new file mode 100644 index 00000000..3bf6c1ea --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_registry_data.py @@ -0,0 +1,386 @@ +"""Verbatim port of veil ``packages/bridge/src/registry/default.ts`` (commit 8d62198, registry version +2026-08-31.solana-deposits.1). Plain dicts only — ``registry.py`` turns them into dataclasses. +Every key keeps veil's camelCase spelling so the brief and veil tests stay the source of truth.""" +from __future__ import annotations + +REGISTRY_VERSION = "2026-08-31.solana-deposits.1" + +EVM_ADDRESS = "^0x[0-9a-fA-F]{40}$" +SOLANA_ADDRESS = "^[1-9A-HJ-NP-Za-km-z]{32,44}$" +ALEO_ADDRESS = "^aleo1[0-9a-z]{58}$" + +CHAINS = [ + {"id": "aleo", "displayName": "Aleo", "family": "aleo", "environment": "mainnet", "nativeCurrencySymbol": "ALEO", + "protocolDomains": {"xreserve": 10002, "hyperlane": 1634493807}}, + {"id": "ethereum", "displayName": "Ethereum", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"xreserve": 0, "hyperlane": 1}}, + {"id": "solana", "displayName": "Solana", "family": "solana", "environment": "mainnet", "nativeCurrencySymbol": "SOL", + "protocolDomains": {"hyperlane": 1399811149}}, + {"id": "base", "displayName": "Base", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "ETH"}, + {"id": "hyperevm", "displayName": "HyperEVM", "family": "evm", "environment": "mainnet", "nativeCurrencySymbol": "HYPE"}, + {"id": "aleo-testnet", "displayName": "Aleo Testnet", "family": "aleo", "environment": "testnet", "nativeCurrencySymbol": "ALEO", + "protocolDomains": {"xreserve": 10002, "hyperlane": 1617853565}}, + {"id": "sepolia", "displayName": "Ethereum Sepolia", "family": "evm", "environment": "testnet", "nativeCurrencySymbol": "ETH", + "protocolDomains": {"hyperlane": 11155111}}, +] + +ASSETS = [ + {"id": "aleo/aleo", "key": "aleo", "chainId": "aleo", "symbol": "ALEO", "name": "Aleo", "decimals": 6, "kind": "native", + "locator": {"kind": "aleo-program", "value": "credits.aleo"}, "addressValidationRegex": ALEO_ADDRESS}, + {"id": "aleo/usdcx", "key": "usdcx", "chainId": "aleo", "symbol": "USDCx", "name": "USDCx", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "usdcx_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS, + "privacy": {"kind": "arc22", "program": "usdcx_stablecoin.aleo"}}, + {"id": "aleo/eth", "key": "eth", "chainId": "aleo", "symbol": "ETH", "name": "Hyperlane ETH", "decimals": 18, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_eth_v2.aleo", + "tokenId": "aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_eth.aleo"}}, + {"id": "aleo/wbtc", "key": "wbtc", "chainId": "aleo", "symbol": "WBTC", "name": "Hyperlane WBTC", "decimals": 8, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_wbtc_v2.aleo", + "tokenId": "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_wbtc.aleo"}}, + {"id": "aleo/usdt", "key": "usdt", "chainId": "aleo", "symbol": "USDT", "name": "Hyperlane USDT", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_usdt_v2.aleo", + "tokenId": "aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_usdt.aleo"}}, + {"id": "aleo/sol", "key": "sol", "chainId": "aleo", "symbol": "SOL", "name": "Hyperlane SOL", "decimals": 9, "kind": "token", + "locator": {"kind": "aleo-program", "value": "hyp_warp_token_sol_v2.aleo", + "tokenId": "aleo1aa0zt0vg9uwknekpqeefkvad55swp7833wc5crp2prv0lm4djuxs5r7k6v"}, + "addressValidationRegex": ALEO_ADDRESS, "privacy": {"kind": "arc20", "program": "arc20_sol.aleo"}}, + {"id": "aleo/usad", "key": "usad", "chainId": "aleo", "symbol": "USAD", "name": "USAD", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "usad_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS}, + {"id": "ethereum/usdc", "key": "usdc", "chainId": "ethereum", "symbol": "USDC", "name": "USD Coin", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/eth", "key": "eth", "chainId": "ethereum", "symbol": "ETH", "name": "Ether", "decimals": 18, "kind": "native", + "locator": {"kind": "native", "value": "ETH"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/wbtc", "key": "wbtc", "chainId": "ethereum", "symbol": "WBTC", "name": "Wrapped Bitcoin", "decimals": 8, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/usdt", "key": "usdt", "chainId": "ethereum", "symbol": "USDT", "name": "Tether USD", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0xdAC17F958D2ee523a2206206994597C13D831ec7"}, "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/aleo", "key": "aleo", "chainId": "ethereum", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "ethereum/usad", "key": "usad", "chainId": "ethereum", "symbol": "USAD", "name": "USAD route collateral", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "solana/sol", "key": "sol", "chainId": "solana", "symbol": "SOL", "name": "Solana", "decimals": 9, "kind": "native", + "locator": {"kind": "native", "value": "SOL"}, "addressValidationRegex": SOLANA_ADDRESS}, + {"id": "solana/aleo", "key": "aleo", "chainId": "solana", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": SOLANA_ADDRESS}, + {"id": "base/aleo", "key": "aleo", "chainId": "base", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "hyperevm/aleo", "key": "aleo", "chainId": "hyperevm", "symbol": "ALEO", "name": "Hyperlane ALEO", "decimals": 6, "kind": "token", + "addressValidationRegex": EVM_ADDRESS}, + {"id": "aleo-testnet/usdcx", "key": "usdcx", "chainId": "aleo-testnet", "symbol": "USDCx", "name": "Testnet USDCx", "decimals": 6, "kind": "token", + "locator": {"kind": "aleo-program", "value": "test_usdcx_stablecoin.aleo"}, "addressValidationRegex": ALEO_ADDRESS, + "privacy": {"kind": "arc22", "program": "test_usdcx_stablecoin.aleo"}}, + {"id": "sepolia/usdc", "key": "usdc", "chainId": "sepolia", "symbol": "USDC", "name": "Testnet USD Coin", "decimals": 6, "kind": "token", + "locator": {"kind": "evm-contract", "value": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}, "addressValidationRegex": EVM_ADDRESS}, +] + +XRESERVE_SOURCE = "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains" +HYPERLANE_REGISTRY_COMMIT = "2621c16f2db1ccb46643265c110dac5ca2c7c51a" +HYPERLANE_SOURCE = f"https://github.com/hyperlane-xyz/hyperlane-registry/tree/{HYPERLANE_REGISTRY_COMMIT}/deployments/warp_routes" + +ALEO_USDT_HYPERLANE_CONFIG_SOURCE = "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/USDT/aleo-config.yaml" +ALEO_SOL_HYPERLANE_CONFIG_SOURCE = "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml" + +ETHEREUM_HYPERLANE_COMMON = { + "sourceChainId": 1, + "destinationDomain": 1634493807, + "mailboxAddress": "0xc005dc82818d67AF737725bD4bf75435d065D239", + "interchainGasPaymaster": "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611", + "interchainSecurityModule": "0x0000000000000000000000000000000000000000", + "registryCommit": HYPERLANE_REGISTRY_COMMIT, +} +ETH_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "routerType": "native", + "destinationRouter": "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8", +} +WBTC_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "routerType": "collateral", + "tokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "destinationRouter": "hyp_warp_token_wbtc_v2.aleo/aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf", +} +USDT_HYPERLANE_METADATA = { + **ETHEREUM_HYPERLANE_COMMON, + "routerAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "routerType": "collateral", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "destinationRouter": "hyp_warp_token_usdt_v2.aleo/aleo18yynfz0lrfx0tund540vy2z7gju7ekgqsueg5jgu28mpm2z42ufq7qua8y", + "requiresApprovalReset": True, +} + +# Intentionally non-live values that only expose the transfer_remote ABI; execution refuses them. +ALEO_PLACEHOLDER_ADDRESS = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO_PLACEHOLDER_BYTES32 = "[" + ", ".join(["0u8"] * 32) + "]" +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +IGP_HOOK = "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" + +ALEO_MAILBOX_METADATA = { + "aleoMailboxStateVerified": True, + "aleoHookManagerProgram": "hyp_hook_manager.aleo", + "aleoHookManagerProgramSource": "https://explorer.provable.com/program/hyp_hook_manager.aleo", + "aleoMailboxProgram": "hyp_mailbox.aleo", + "aleoMailboxProgramEdition": 0, + "aleoMailboxProgramSource": "https://explorer.provable.com/program/hyp_mailbox.aleo", + "aleoMailboxMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_mailbox.aleo/mapping/mailbox/true", + "aleoMailboxMetadataReviewedAt": "2026-08-17", + "aleoMailboxLocalDomain": 1634493807, + "aleoMailboxObservedNonce": 170, + "aleoMailboxObservedProcessCount": 291, + "aleoMailboxDefaultIsm": "aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk", + "aleoMailboxDefaultHook": IGP_HOOK, + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", +} + + +def _aleo_hyperlane_placeholders(program: str, destination_domain: int) -> dict: + return { + "aleoRouterProgram": program, + "aleoDestinationDomain": destination_domain, + "aleoPlaceholderConfiguration": True, + "aleoTokenType": "0", + "aleoTokenOwner": ALEO_PLACEHOLDER_ADDRESS, + "aleoIsm": ALEO_PLACEHOLDER_ADDRESS, + "aleoHook": ALEO_PLACEHOLDER_ADDRESS, + "aleoTokenId": "0field", + "aleoRemoteRouterRecipient": ALEO_PLACEHOLDER_BYTES32, + "aleoRemoteRouterGas": "0", + "aleoRecipient": "[0u128, 0u128]", + "aleoAllowanceSpender0": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount0": "0", + "aleoAllowanceSpender1": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount1": "0", + "aleoAllowanceSpender2": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount2": "0", + "aleoAllowanceSpender3": ALEO_PLACEHOLDER_ADDRESS, + "aleoAllowanceAmount3": "0", + **ALEO_MAILBOX_METADATA, + } + + +ALEO_WBTC_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_wbtc_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "1505227928464760254508513036497943623956572091841806589002910775534260084309field", + "aleoLocalDecimals": 8, + "aleoRemoteDecimals": 8, +} +ALEO_ETH_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_eth_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_eth_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "133188123661477349522757068766864658505569365361420630212878794317749195359field", + "aleoLocalDecimals": 18, + "aleoRemoteDecimals": 18, +} +ALEO_USDT_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_usdt_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_usdt_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 1, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "8295938150000417034830036849466229528602563851235385582732969109393809606969field", + "aleoLocalDecimals": 6, + "aleoRemoteDecimals": 18, + "aleoScale": "1000000000000", + "aleoHyperlaneConfigSource": ALEO_USDT_HYPERLANE_CONFIG_SOURCE, +} +ALEO_SOL_APP_METADATA = { + "aleoAppMetadataVerified": True, + "aleoProgramSource": "https://explorer.provable.com/program/hyp_warp_token_sol_v2.aleo", + "aleoAppMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_sol_v2.aleo/mapping/app_metadata/true", + "aleoAppMetadataReviewedAt": "2026-08-17", + "aleoProgramEdition": 0, + "aleoTokenType": "1", + "aleoTokenOwner": "aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45", + "aleoIsm": ZERO_ADDRESS, + "aleoHook": ZERO_ADDRESS, + "aleoTokenId": "6148061383892805373029428966764338809222769879628268522058032128225601478383field", + "aleoLocalDecimals": 9, + "aleoRemoteDecimals": 9, + "aleoHyperlaneConfigSource": ALEO_SOL_HYPERLANE_CONFIG_SOURCE, +} + +_ALLOWANCES = { + "aleoAllowanceSpendersVerified": True, + "aleoUnusedAllowancesVerified": True, + "aleoAllowanceSpender0": IGP_HOOK, + "aleoAllowanceSpender1": ZERO_ADDRESS, + "aleoAllowanceSpender2": ZERO_ADDRESS, + "aleoAllowanceSpender3": ZERO_ADDRESS, + "aleoAllowanceAmount1": "0", + "aleoAllowanceAmount2": "0", + "aleoAllowanceAmount3": "0", +} +ALEO_ETH_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_eth_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransferSource": "https://explorer.provable.com/transaction/at1vu0yckkms887zkl3qz7plnncd56jtf5zeal4uj2808upsjkusy8q7yp9v8", + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8]", + "aleoRemoteRouterGas": "44000", + **_ALLOWANCES, +} +ALEO_WBTC_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x20CDC85778b732073F7EecEF3DF25c0d310f8772", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8]", + "aleoRemoteRouterGas": "68000", + **_ALLOWANCES, +} +ALEO_USDT_ETHEREUM_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_usdt_v2.aleo/mapping/remote_routers/1u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransferSource": "https://explorer.provable.com/transaction/at19caeeee8v3xc4kfwen4tx89f0tnggrpjp0anrhq2ca3y82xr9q8qyz8a9r", + "aleoSampleTransferDestinationDomain": 56, + "aleoDestinationDomain": 1, + "aleoRemoteRouterEvmAddress": "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", + "aleoRemoteRouterRecipient": "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8]", + "aleoRemoteRouterGas": "68000", + **_ALLOWANCES, +} +ALEO_SOL_REMOTE_ROUTER = { + "aleoRemoteRouterVerified": True, + "aleoRemoteRouterSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_sol_v2.aleo/mapping/remote_routers/1399811149u32", + "aleoRemoteRouterReviewedAt": "2026-08-17", + "aleoSampleTransitionId": "au15fg39h53h55tkj0nexrme3k6pvgxngxapcyajdhf06jcg3cyeugq5kd7hg", + "aleoDestinationDomain": 1399811149, + "aleoRemoteRouterSolanaAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "aleoRemoteRouterRecipient": "[112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8]", + "aleoRemoteRouterGas": "300000", + **_ALLOWANCES, +} +ALEO_WITHDRAWAL_ACTIVATION = {"aleoPlaceholderConfiguration": False, "aleoWithdrawalReviewedAt": "2026-08-26"} + +SOLANA_SOL_DEPOSIT_METADATA = { + "warpProgramAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", + "tokenPda": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "nativeCollateralPda": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", + "dispatchAuthorityPda": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "mailboxProgramAddress": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", + "mailboxOutboxPda": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "igpProgramAddress": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", + "igpProgramDataPda": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "igpAccount": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "igpOverheadAccount": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "splNoopProgramAddress": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", + "destinationDomain": 1634493807, + "destinationGasAmount": "464000", + "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", + "solanaReviewedAt": "2026-08-31", + "solanaConfigSource": ALEO_SOL_HYPERLANE_CONFIG_SOURCE, +} + +XRESERVE_MAINNET_METADATA = { + "xReserveContract": "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", + "sourceChainId": 1, + "sourceDomain": 0, + "ethereumDestinationDomain": 0, + "arcDestinationDomain": 26, + "remoteDomain": 10002, + "remoteToken": "usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711", + "minimumAmountAtomic": "2000000", + "withdrawalFeeAtomic": "2000000", + "maxFeeAtomic": "100000", + "bridgeProgram": "usdcx_bridge_v2.aleo", + "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api.circle.com/v1/attestations", +} +XRESERVE_TESTNET_METADATA = { + "xReserveContract": "0x008888878f94C0d87defdf0B07f46B93C1934442", + "sourceChainId": 11155111, + "sourceDomain": 0, + "ethereumDestinationDomain": 0, + "arcDestinationDomain": 26, + "remoteDomain": 10002, + "remoteToken": "test_usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401", + "minimumAmountAtomic": "2000000", + "withdrawalFeeAtomic": "2000000", + "maxFeeAtomic": "100000", + "bridgeProgram": "test_usdcx_bridge_v2.aleo", + "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api-testnet.circle.com/v1/attestations", +} + + +def _route(id: str, protocol: str, environment: str, source_asset_id: str, destination_asset_id: str, + availability: str, deployment_id: str, metadata: dict) -> dict: + return { + "id": id, "protocol": protocol, "environment": environment, + "sourceAssetId": source_asset_id, "destinationAssetId": destination_asset_id, + "availability": availability, "deploymentId": deployment_id, + "source": XRESERVE_SOURCE if protocol == "xreserve" else HYPERLANE_SOURCE, + "metadata": dict(metadata), + } + + +def _pair(protocol: str, environment: str, left: str, right: str, availability: str, + deployment_id: str, metadata: dict) -> list[dict]: + return [ + _route(f"{protocol}:{left}->{right}", protocol, environment, left, right, availability, deployment_id, metadata), + _route(f"{protocol}:{right}->{left}", protocol, environment, right, left, availability, deployment_id, metadata), + ] + + +ROUTES = [ + *_pair("xreserve", "mainnet", "ethereum/usdc", "aleo/usdcx", "active", "xreserve-usdcx-aleo", XRESERVE_MAINNET_METADATA), + *_pair("xreserve", "testnet", "sepolia/usdc", "aleo-testnet/usdcx", "active", "xreserve-usdcx-aleo-testnet", XRESERVE_TESTNET_METADATA), + _route("hyperlane:ethereum/eth->aleo/eth", "hyperlane", "mainnet", "ethereum/eth", "aleo/eth", "active", "ETH/aleo", + {**ETH_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/eth->ethereum/eth", "hyperlane", "mainnet", "aleo/eth", "ethereum/eth", "active", "ETH/aleo", + {**ETH_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_eth_v2.aleo", 1), + **ALEO_ETH_APP_METADATA, **ALEO_ETH_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:ethereum/wbtc->aleo/wbtc", "hyperlane", "mainnet", "ethereum/wbtc", "aleo/wbtc", "active", "WBTC/aleo", + {**WBTC_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/wbtc->ethereum/wbtc", "hyperlane", "mainnet", "aleo/wbtc", "ethereum/wbtc", "active", "WBTC/aleo", + {**WBTC_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_wbtc_v2.aleo", 1), + **ALEO_WBTC_APP_METADATA, **ALEO_WBTC_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:ethereum/usdt->aleo/usdt", "hyperlane", "mainnet", "ethereum/usdt", "aleo/usdt", "active", "USDT/aleo", + {**USDT_HYPERLANE_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/usdt->ethereum/usdt", "hyperlane", "mainnet", "aleo/usdt", "ethereum/usdt", "active", "USDT/aleo", + {**USDT_HYPERLANE_METADATA, **_aleo_hyperlane_placeholders("hyp_warp_token_usdt_v2.aleo", 1), + **ALEO_USDT_APP_METADATA, **ALEO_USDT_ETHEREUM_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + _route("hyperlane:solana/sol->aleo/sol", "hyperlane", "mainnet", "solana/sol", "aleo/sol", "active", "SOL/aleo", + {**SOLANA_SOL_DEPOSIT_METADATA, **ALEO_MAILBOX_METADATA}), + _route("hyperlane:aleo/sol->solana/sol", "hyperlane", "mainnet", "aleo/sol", "solana/sol", "active", "SOL/aleo", + {**_aleo_hyperlane_placeholders("hyp_warp_token_sol_v2.aleo", 1399811149), + **ALEO_SOL_APP_METADATA, **ALEO_SOL_REMOTE_ROUTER, **ALEO_WITHDRAWAL_ACTIVATION}), + *_pair("hyperlane", "mainnet", "aleo/aleo", "ethereum/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "solana/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "base/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + *_pair("hyperlane", "mainnet", "aleo/aleo", "hyperevm/aleo", "metadata-required", "ALEO/aleo", ALEO_MAILBOX_METADATA), + _route("hyperlane:ethereum/usad->aleo/usad", "hyperlane", "mainnet", "ethereum/usad", "aleo/usad", "metadata-required", "USAD/aleo", + ALEO_MAILBOX_METADATA), + _route("hyperlane:aleo/usad->ethereum/usad", "hyperlane", "mainnet", "aleo/usad", "ethereum/usad", "metadata-required", "USAD/aleo", + _aleo_hyperlane_placeholders("hyp_warp_token_usad_v2.aleo", 1)), +] diff --git a/bridge-sdk/python/aleo_bridge/registry.py b/bridge-sdk/python/aleo_bridge/registry.py new file mode 100644 index 00000000..46f517b1 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/registry.py @@ -0,0 +1,290 @@ +"""Typed, validated views over the pinned deployment registry (``_registry_data.py``). + +Discovery is local: chains, assets and routes come from a reviewed snapshot, never from live lookups. +Assets are addressed as ``"chain/key"`` or ``(chain, key)``; symbols and chain ids compare +case-insensitively. Route ``metadata`` keeps veil's camelCase keys. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from types import ModuleType +from typing import Any, Iterable, Mapping + +from . import _registry_data +from .errors import AmbiguousRouteError, ConfigurationError, RouteNotFoundError + +AssetRef = "str | tuple[str, str] | Asset" +FAMILIES = ("aleo", "evm", "solana") +AVAILABILITIES = ("active", "metadata-required", "disabled") +PRIVACY_KINDS = ("arc20", "arc22") +_SOLANA_REQUIRED_METADATA = ( + "warpProgramAddress", "tokenPda", "nativeCollateralPda", "dispatchAuthorityPda", "mailboxProgramAddress", + "mailboxOutboxPda", "igpProgramAddress", "igpProgramDataPda", "igpAccount", "splNoopProgramAddress", + "destinationDomain", "destinationGasAmount", "registryCommit", "solanaReviewedAt", "solanaConfigSource", +) + + +@dataclass(frozen=True) +class Chain: + id: str + display_name: str + family: str # "aleo" | "evm" | "solana" + environment: str # "mainnet" | "testnet" + native_symbol: str + protocol_domains: Mapping[str, int] = field(default_factory=dict) # {"xreserve": 0, "hyperlane": 1} + + +@dataclass(frozen=True) +class Locator: + kind: str # "aleo-program" | "evm-contract" | "solana-mint" | "native" + value: str + token_id: str | None = None + + +@dataclass(frozen=True) +class Privacy: + kind: str # "arc20" | "arc22" + program: str + + +@dataclass(frozen=True) +class Asset: + id: str # "chain/key" + key: str + chain_id: str + symbol: str + name: str + decimals: int + kind: str # "native" | "token" + locator: Locator | None = None + address_regex: str | None = None + privacy: Privacy | None = None + + def matches_address(self, value: str) -> bool: + """Whether *value* matches this asset's chain address format (False when no regex is declared).""" + return bool(self.address_regex) and isinstance(value, str) and re.search(self.address_regex, value) is not None + + +@dataclass(frozen=True) +class Route: + id: str # "protocol:source->destination" + protocol: str # "xreserve" | "hyperlane" + environment: str + source_asset_id: str + destination_asset_id: str + availability: str # "active" | "metadata-required" | "disabled" + deployment_id: str | None = None + source: str | None = None + metadata: Mapping[str, "str | int | bool"] = field(default_factory=dict) + + @property + def active(self) -> bool: + return self.availability == "active" + + def meta_str(self, key: str) -> str: + value = self.metadata.get(key) + if not isinstance(value, str) or not value: + raise ConfigurationError(f"Route metadata {key} is missing: {self.id}") + return value + + def meta_int(self, key: str, default: int | None = None) -> int: + value = self.metadata.get(key, default) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ConfigurationError(f"Route metadata {key} is invalid: {self.id}") + return value + + +def _parse_asset_ref(ref: Any) -> tuple[str, str]: + if isinstance(ref, tuple) and len(ref) == 2: + return str(ref[0]).lower(), str(ref[1]).lower() + if isinstance(ref, str) and ref.count("/") == 1: + chain, key = ref.split("/") + return chain.lower(), key.lower() + raise RouteNotFoundError(f"Asset reference must be 'chain/key' or (chain, key), got {ref!r}") + + +class Registry: + """Immutable snapshot with case-insensitive lookups. Build with :func:`build_registry` or directly.""" + + def __init__(self, version: str, chains: Iterable[Chain], assets: Iterable[Asset], routes: Iterable[Route]) -> None: + self.version = version + self._chains = tuple(chains) + self._assets = tuple(assets) + self._routes = tuple(routes) + self._chain_by_id = {c.id: c for c in self._chains} + self._asset_by_id = {a.id: a for a in self._assets} + self._route_by_id = {r.id: r for r in self._routes} + + def __repr__(self) -> str: + return f"Registry(version={self.version!r}, chains={len(self._chains)}, assets={len(self._assets)}, routes={len(self._routes)})" + + # ── chains ── + def chains(self, environment: str | None = None) -> list[Chain]: + return [c for c in self._chains if environment is None or c.environment == environment] + + def chain(self, chain_id: str) -> Chain: + for c in self._chains: + if c.id.lower() == str(chain_id).lower(): + return c + raise RouteNotFoundError(f"Unknown bridge chain: {chain_id}") + + # ── assets ── + def assets(self, chain: str | None = None, symbol: str | None = None, environment: str | None = None) -> list[Asset]: + out = [] + for a in self._assets: + if chain is not None and a.chain_id.lower() != chain.lower(): + continue + if symbol is not None and a.symbol.lower() != symbol.lower(): + continue + if environment is not None and self._chain_by_id[a.chain_id].environment != environment: + continue + out.append(a) + return out + + def asset(self, ref: Any) -> Asset: + if isinstance(ref, Asset): + return ref + chain, key = _parse_asset_ref(ref) + for a in self._assets: + if a.chain_id.lower() == chain and a.key.lower() == key: + return a + raise RouteNotFoundError(f"Unknown bridge asset: {chain}/{key}") + + # ── routes ── + def _endpoint_matches(self, asset_id: str, selector: str | None) -> bool: + if selector is None: + return True + asset = self._asset_by_id[asset_id] + if "/" in selector: + return asset.id.lower() == selector.lower() + return asset.chain_id.lower() == selector.lower() + + def routes(self, source: str | None = None, destination: str | None = None, protocol: str | None = None, + symbol: str | None = None, include_unavailable: bool = False, environment: str | None = None) -> list[Route]: + """Filter routes; *source*/*destination* accept a chain id or an ``"chain/key"`` asset ref. + Disabled routes are hidden unless *include_unavailable*; metadata-required routes are always listed.""" + out = [] + for r in self._routes: + if not include_unavailable and r.availability == "disabled": + continue + if environment is not None and r.environment != environment: + continue + if protocol is not None and r.protocol != protocol: + continue + if not self._endpoint_matches(r.source_asset_id, source) or not self._endpoint_matches(r.destination_asset_id, destination): + continue + if symbol is not None: + symbols = {self._asset_by_id[r.source_asset_id].symbol.lower(), self._asset_by_id[r.destination_asset_id].symbol.lower()} + if symbol.lower() not in symbols: + continue + out.append(r) + return out + + def route(self, route_id: str) -> Route: + try: + return self._route_by_id[route_id] + except KeyError: + raise RouteNotFoundError(f"Unknown bridge route: {route_id}") from None + + def find_route(self, source: Any, destination: Any, protocol: str | None = None) -> Route: + """prepare()'s lookup: the single non-disabled route for an exact asset pair (metadata-required included).""" + src, dst = self.asset(source), self.asset(destination) + matches = [r for r in self._routes + if r.source_asset_id == src.id and r.destination_asset_id == dst.id + and r.availability != "disabled" and (protocol is None or r.protocol == protocol)] + if not matches: + raise RouteNotFoundError( + f"No bridge route from {src.id} to {dst.id}" + (f" over {protocol}" if protocol else "") + + "; list candidates with registry.routes(source=..., destination=...)") + if len(matches) > 1: + raise AmbiguousRouteError( + f"{len(matches)} routes from {src.id} to {dst.id}: {[r.id for r in matches]} — pass protocol=") + return matches[0] + + +def validate_registry(registry: Registry) -> Registry: + """Port of veil ``validateBridgeRegistry`` (+ the Solana metadata gate). Returns *registry* unchanged.""" + if not registry.version.strip(): + raise ConfigurationError("Bridge registry version must not be empty") + chain_ids: set[str] = set() + for chain in registry._chains: + if chain.id in chain_ids: + raise ConfigurationError(f"Duplicate bridge chain id: {chain.id}") + if chain.family not in FAMILIES: + raise ConfigurationError(f"Bridge chain {chain.id} has unsupported family {chain.family!r}") + chain_ids.add(chain.id) + asset_ids: set[str] = set() + asset_keys: set[str] = set() + for asset in registry._assets: + if asset.id in asset_ids: + raise ConfigurationError(f"Duplicate bridge asset id: {asset.id}") + if asset.chain_id not in chain_ids: + raise ConfigurationError(f"Bridge asset {asset.id} references unknown chain {asset.chain_id}") + if not asset.key.strip(): + raise ConfigurationError(f"Bridge asset {asset.id} has an empty key") + scoped = f"{asset.chain_id}/{asset.key}" + if scoped in asset_keys: + raise ConfigurationError(f"Duplicate bridge asset key: {scoped}") + if isinstance(asset.decimals, bool) or not isinstance(asset.decimals, int) or asset.decimals < 0: + raise ConfigurationError(f"Bridge asset {asset.id} has invalid decimals {asset.decimals}") + if asset.address_regex: + try: + re.compile(asset.address_regex) + except re.error as exc: + raise ConfigurationError(f"Bridge asset {asset.id} has an invalid address validation regex") from exc + if asset.privacy is not None: + if registry._chain_by_id[asset.chain_id].family != "aleo": + raise ConfigurationError(f"Bridge asset {asset.id} declares a privacy capability on a non-Aleo chain") + if not asset.privacy.program.strip(): + raise ConfigurationError(f"Bridge asset {asset.id} has an empty privacy program") + if asset.privacy.kind not in PRIVACY_KINDS: + raise ConfigurationError(f"Bridge asset {asset.id} has an unsupported privacy capability kind") + asset_ids.add(asset.id) + asset_keys.add(scoped) + route_ids: set[str] = set() + for route in registry._routes: + if route.id in route_ids: + raise ConfigurationError(f"Duplicate bridge route id: {route.id}") + if route.source_asset_id not in asset_ids: + raise ConfigurationError(f"Bridge route {route.id} references unknown source asset {route.source_asset_id}") + if route.destination_asset_id not in asset_ids: + raise ConfigurationError(f"Bridge route {route.id} references unknown destination asset {route.destination_asset_id}") + if route.availability not in AVAILABILITIES: + raise ConfigurationError(f"Bridge route {route.id} has unsupported availability {route.availability!r}") + source_chain = registry._chain_by_id[registry._asset_by_id[route.source_asset_id].chain_id] + destination_chain = registry._chain_by_id[registry._asset_by_id[route.destination_asset_id].chain_id] + if source_chain.environment != route.environment or destination_chain.environment != route.environment: + raise ConfigurationError(f"Bridge route {route.id} crosses registry environments") + if route.protocol == "hyperlane" and route.availability == "active" and source_chain.family == "solana": + for key in _SOLANA_REQUIRED_METADATA: + value = route.metadata.get(key) + ok = isinstance(value, int) and not isinstance(value, bool) if key == "destinationDomain" \ + else isinstance(value, str) and bool(value) + if not ok: + raise ConfigurationError(f"Bridge route {route.id} is active but missing required Solana Hyperlane metadata") + route_ids.add(route.id) + return registry + + +def build_registry(data: ModuleType = _registry_data) -> Registry: + """Turn the plain-dict literals of a data module into a validated :class:`Registry`.""" + chains = [Chain(c["id"], c["displayName"], c["family"], c["environment"], c["nativeCurrencySymbol"], + dict(c.get("protocolDomains", {}))) for c in data.CHAINS] + assets = [] + for a in data.ASSETS: + loc = a.get("locator") + priv = a.get("privacy") + assets.append(Asset(a["id"], a["key"], a["chainId"], a["symbol"], a["name"], a["decimals"], a["kind"], + Locator(loc["kind"], loc["value"], loc.get("tokenId")) if loc else None, + a.get("addressValidationRegex"), + Privacy(priv["kind"], priv["program"]) if priv else None)) + routes = [Route(r["id"], r["protocol"], r["environment"], r["sourceAssetId"], r["destinationAssetId"], + r["availability"], r.get("deploymentId"), r.get("source"), dict(r.get("metadata", {}))) + for r in data.ROUTES] + return validate_registry(Registry(data.REGISTRY_VERSION, chains, assets, routes)) + + +DEFAULT_REGISTRY: Registry = build_registry() + +__all__ = ["Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "build_registry", "validate_registry"] diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py new file mode 100644 index 00000000..484e598c --- /dev/null +++ b/bridge-sdk/tests/test_registry.py @@ -0,0 +1,322 @@ +import re + +import pytest + +from aleo_bridge import _registry_data as data +from aleo_bridge.errors import AmbiguousRouteError, ConfigurationError, RouteNotFoundError +from aleo_bridge.registry import (DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, + validate_registry) + +REG = DEFAULT_REGISTRY +MAILBOX = { + "aleoMailboxStateVerified": True, "aleoMailboxProgram": "hyp_mailbox.aleo", "aleoMailboxProgramEdition": 0, + "aleoMailboxLocalDomain": 1634493807, "aleoMailboxObservedNonce": 170, "aleoMailboxObservedProcessCount": 291, + "aleoMailboxDefaultIsm": "aleo1yvf5kcsdgnescqq2lar83mms79yh3ugvc3y0mdnlgvx4lyh5zugqr9hptk", + "aleoMailboxDefaultHook": "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74", + "aleoMailboxRequiredHook": "aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82", + "aleoMailboxDispatchProxy": "aleo1sge9kmjzs3d8fqrscy4hwn7vf9vw4jcxe877lv0m2w8hay78lsxsqg975s", + "aleoMailboxOwner": "aleo1ypf8xgvz560ukw25hufj3d77gx69pdcy70nssdfdxd97j80d7cqs98d7x8", +} + + +def test_shape_and_version(): + assert REG.version == "2026-08-31.solana-deposits.1" + assert len(REG.chains()) == 7 and len(REG.assets()) == 19 + assert len(REG.routes(include_unavailable=True)) == 22 + assert len(REG.routes()) == 22 # nothing is 'disabled' in this snapshot; metadata-required stays visible + assert validate_registry(REG) is REG + assert REG.routes(include_unavailable=True)[0].metadata["xReserveContract"] == "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" + + +def test_chains(): + assert [c.id for c in REG.chains()] == ["aleo", "ethereum", "solana", "base", "hyperevm", "aleo-testnet", "sepolia"] + assert [c.id for c in REG.chains(environment="testnet")] == ["aleo-testnet", "sepolia"] + aleo = REG.chain("aleo") + assert (aleo.display_name, aleo.family, aleo.environment, aleo.native_symbol) == ("Aleo", "aleo", "mainnet", "ALEO") + assert aleo.protocol_domains == {"xreserve": 10002, "hyperlane": 1634493807} + assert REG.chain("ethereum").protocol_domains == {"xreserve": 0, "hyperlane": 1} + assert REG.chain("solana").protocol_domains == {"hyperlane": 1399811149} + assert REG.chain("base").protocol_domains == {} and REG.chain("hyperevm").native_symbol == "HYPE" + assert REG.chain("aleo-testnet").protocol_domains == {"xreserve": 10002, "hyperlane": 1617853565} + assert REG.chain("sepolia").protocol_domains == {"hyperlane": 11155111} + with pytest.raises(RouteNotFoundError): + REG.chain("bitcoin") + + +def test_assets_and_lookups(): + wbtc = REG.asset("aleo/wbtc") + assert wbtc == REG.asset(("aleo", "wbtc")) == REG.asset("ALEO/WBTC") + assert (wbtc.key, wbtc.chain_id, wbtc.symbol, wbtc.name, wbtc.decimals, wbtc.kind) == \ + ("wbtc", "aleo", "WBTC", "Hyperlane WBTC", 8, "token") + assert wbtc.locator == Locator("aleo-program", "hyp_warp_token_wbtc_v2.aleo", + "aleo1240fsvz2dhmj0cdtt8mc0yc8um9fmu236rqcl2qnlj9703hd2vpsdwyrtf") + assert wbtc.privacy == Privacy("arc20", "arc20_wbtc.aleo") + assert wbtc.address_regex == "^aleo1[0-9a-z]{58}$" + usdcx = REG.asset("aleo/usdcx") + assert usdcx.locator == Locator("aleo-program", "usdcx_stablecoin.aleo") and usdcx.privacy == Privacy("arc22", "usdcx_stablecoin.aleo") + assert REG.asset("aleo/aleo").privacy is None and REG.asset("aleo/usad").locator == Locator("aleo-program", "usad_stablecoin.aleo") + assert REG.asset("ethereum/usdc").locator == Locator("evm-contract", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + assert REG.asset("ethereum/eth").locator == Locator("native", "ETH") and REG.asset("ethereum/eth").kind == "native" + assert REG.asset("ethereum/wbtc").locator.value == "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + assert REG.asset("ethereum/usdt").locator.value == "0xdAC17F958D2ee523a2206206994597C13D831ec7" + assert REG.asset("sepolia/usdc").locator.value == "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + assert REG.asset("aleo-testnet/usdcx").privacy == Privacy("arc22", "test_usdcx_stablecoin.aleo") + for no_locator in ("ethereum/aleo", "ethereum/usad", "solana/aleo", "base/aleo", "hyperevm/aleo"): + assert REG.asset(no_locator).locator is None + assert REG.asset("solana/sol").address_regex == "^[1-9A-HJ-NP-Za-km-z]{32,44}$" + assert REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001") + assert not REG.asset("aleo/usdcx").matches_address("0xabc") + assert [a.id for a in REG.assets(chain="aleo")] == ["aleo/aleo", "aleo/usdcx", "aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol", "aleo/usad"] + assert [a.id for a in REG.assets(symbol="aleo")] == ["aleo/aleo", "ethereum/aleo", "solana/aleo", "base/aleo", "hyperevm/aleo"] + assert [a.id for a in REG.assets(environment="testnet")] == ["aleo-testnet/usdcx", "sepolia/usdc"] + for bad in ("aleo/doge", "doge", ("aleo", "doge"), "a/b/c"): + with pytest.raises(RouteNotFoundError): + REG.asset(bad) + + +def test_usdcx_only_via_xreserve_and_others_via_hyperlane(): + usdcx = [r for r in REG.routes(include_unavailable=True) if "usdcx" in r.source_asset_id or "usdcx" in r.destination_asset_id] + assert usdcx and all(r.protocol == "xreserve" for r in usdcx) + for symbol in ("ETH", "WBTC", "USDT", "SOL", "ALEO", "USAD"): + routes = REG.routes(symbol=symbol, include_unavailable=True) + assert routes and all(r.protocol == "hyperlane" for r in routes), symbol + + +def test_xreserve_routes(): + xr = REG.routes(protocol="xreserve", include_unavailable=True) + assert [r.id for r in xr] == ["xreserve:ethereum/usdc->aleo/usdcx", "xreserve:aleo/usdcx->ethereum/usdc", + "xreserve:sepolia/usdc->aleo-testnet/usdcx", "xreserve:aleo-testnet/usdcx->sepolia/usdc"] + assert all(r.availability == "active" and r.active for r in xr) + assert all(r.metadata["ethereumDestinationDomain"] == 0 and r.metadata["arcDestinationDomain"] == 26 for r in xr) + assert all(r.source == "https://developers.circle.com/xreserve/references/supported-blockchains-and-domains" for r in xr) + main = REG.route("xreserve:aleo/usdcx->ethereum/usdc").metadata + assert main == { + "xReserveContract": "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce", "sourceChainId": 1, "sourceDomain": 0, + "ethereumDestinationDomain": 0, "arcDestinationDomain": 26, "remoteDomain": 10002, + "remoteToken": "usdcx_stablecoin.aleo", + "remoteTokenBytes32": "0x11ea7dab1d29d5f61500582c63e98c42e1165f9ba050ea9d0c6af9f871987711", + "minimumAmountAtomic": "2000000", "withdrawalFeeAtomic": "2000000", "maxFeeAtomic": "100000", + "bridgeProgram": "usdcx_bridge_v2.aleo", "wrapperProgram": "shielded_usdcx_wrapper.aleo", + "attestationBaseUrl": "https://xreserve-api.circle.com/v1/attestations", + } + test = REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx").metadata + assert test["xReserveContract"] == "0x008888878f94C0d87defdf0B07f46B93C1934442" and test["sourceChainId"] == 11155111 + assert test["remoteToken"] == "test_usdcx_stablecoin.aleo" and test["bridgeProgram"] == "test_usdcx_bridge_v2.aleo" + assert test["remoteTokenBytes32"] == "0xb143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401" + assert test["attestationBaseUrl"] == "https://xreserve-api-testnet.circle.com/v1/attestations" + assert REG.route("xreserve:ethereum/usdc->aleo/usdcx").deployment_id == "xreserve-usdcx-aleo" + assert REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx").deployment_id == "xreserve-usdcx-aleo-testnet" + + +def test_inbound_ethereum_hyperlane_routes(): + inbound = [REG.route(i) for i in ("hyperlane:ethereum/eth->aleo/eth", "hyperlane:ethereum/wbtc->aleo/wbtc", "hyperlane:ethereum/usdt->aleo/usdt")] + assert all(r.active for r in inbound) + for r in inbound: + m = r.metadata + assert m["registryCommit"] == "2621c16f2db1ccb46643265c110dac5ca2c7c51a" + assert m["sourceChainId"] == 1 and m["destinationDomain"] == 1634493807 + assert m["mailboxAddress"] == "0xc005dc82818d67AF737725bD4bf75435d065D239" + assert m["interchainGasPaymaster"] == "0x9e6B1022bE9BBF5aFd152483DAD9b88911bC8611" + assert m["interchainSecurityModule"] == "0x0000000000000000000000000000000000000000" + for k, v in MAILBOX.items(): + assert m[k] == v + assert r.source == "https://github.com/hyperlane-xyz/hyperlane-registry/tree/2621c16f2db1ccb46643265c110dac5ca2c7c51a/deployments/warp_routes" + eth, wbtc, usdt = (r.metadata for r in inbound) + assert (eth["routerAddress"], eth["routerType"]) == ("0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A", "native") + assert eth["destinationRouter"] == "hyp_warp_token_eth_v2.aleo/aleo1t7f29tq9qng2lfvrkpcuvu59jn24hrmzqdyqfn6p0u5p80npfvqqecmkj8" + assert (wbtc["routerAddress"], wbtc["routerType"], wbtc["tokenAddress"]) == \ + ("0x20CDC85778b732073F7EecEF3DF25c0d310f8772", "collateral", "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599") + assert (usdt["routerAddress"], usdt["tokenAddress"], usdt["requiresApprovalReset"]) == \ + ("0x3C2064D78e4578E8F936E3db42aEF044E33FBF31", "0xdAC17F958D2ee523a2206206994597C13D831ec7", True) + assert "requiresApprovalReset" not in wbtc + + +def test_aleo_origin_withdrawals_are_active_and_pinned(): + ids = ["hyperlane:aleo/eth->ethereum/eth", "hyperlane:aleo/wbtc->ethereum/wbtc", + "hyperlane:aleo/usdt->ethereum/usdt", "hyperlane:aleo/sol->solana/sol"] + routes = [REG.route(i) for i in ids] + assert all(r.active for r in routes) + assert all(r.metadata["aleoPlaceholderConfiguration"] is False for r in routes) + assert all(r.metadata["aleoWithdrawalReviewedAt"] == "2026-08-26" for r in routes) + assert all(r.metadata["aleoHookManagerProgram"] == "hyp_hook_manager.aleo" for r in routes) + assert [r.metadata["aleoRouterProgram"] for r in routes] == [ + "hyp_warp_token_eth_v2.aleo", "hyp_warp_token_wbtc_v2.aleo", "hyp_warp_token_usdt_v2.aleo", "hyp_warp_token_sol_v2.aleo"] + for r in routes: + m = r.metadata + assert m["aleoAppMetadataVerified"] is True and m["aleoRemoteRouterVerified"] is True + assert m["aleoAllowanceSpendersVerified"] is True and m["aleoUnusedAllowancesVerified"] is True + assert m["aleoTokenType"] == "1" + assert m["aleoIsm"] == m["aleoHook"] == "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + assert m["aleoAllowanceSpender0"] == "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" + assert m["aleoAllowanceSpender1"] == m["aleoAllowanceSpender2"] == m["aleoAllowanceSpender3"] == \ + "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" + assert m["aleoAllowanceAmount1"] == m["aleoAllowanceAmount2"] == m["aleoAllowanceAmount3"] == "0" + # slot 0's pinned "0" is a placeholder; the live IGP quote is fetched at execution time (out of + # scope for the registry), matching veil's default.ts/dist behavior (placeholderFields mechanism). + assert m["aleoAllowanceAmount0"] == "0" + for k, v in MAILBOX.items(): + assert m[k] == v + + +def test_eth_wbtc_usdt_sol_metadata_literals(): + eth = REG.route("hyperlane:aleo/eth->ethereum/eth").metadata + assert eth["aleoTokenOwner"] == "aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r" + assert eth["aleoTokenId"] == "133188123661477349522757068766864658505569365361420630212878794317749195359field" + assert (eth["aleoLocalDecimals"], eth["aleoRemoteDecimals"], eth["aleoProgramEdition"], eth["aleoDestinationDomain"]) == (18, 18, 0, 1) + assert eth["aleoRemoteRouterEvmAddress"] == "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" and eth["aleoRemoteRouterGas"] == "44000" + assert eth["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, 243u8, 11u8, 126u8, 193u8, 250u8, 90u8]" + assert eth["aleoSampleTransferSource"] == "https://explorer.provable.com/transaction/at1vu0yckkms887zkl3qz7plnncd56jtf5zeal4uj2808upsjkusy8q7yp9v8" + assert eth["routerAddress"] == "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" # ETH_HYPERLANE_METADATA is merged into the reverse route too + + wbtc = REG.route("hyperlane:aleo/wbtc->ethereum/wbtc").metadata + assert wbtc["aleoTokenOwner"] == "aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95" + assert wbtc["aleoTokenId"] == "1505227928464760254508513036497943623956572091841806589002910775534260084309field" + assert (wbtc["aleoLocalDecimals"], wbtc["aleoRemoteDecimals"], wbtc["aleoProgramEdition"]) == (8, 8, 0) + assert wbtc["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, 92u8, 13u8, 49u8, 15u8, 135u8, 114u8]" + assert wbtc["aleoRemoteRouterGas"] == "68000" + assert wbtc["aleoProgramSource"] == "https://explorer.provable.com/program/hyp_warp_token_wbtc_v2.aleo" + assert wbtc["aleoAppMetadataSource"] == "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/app_metadata/true" + assert wbtc["aleoRemoteRouterSource"] == "https://api.explorer.provable.com/v2/mainnet/program/hyp_warp_token_wbtc_v2.aleo/mapping/remote_routers/1u32" + assert wbtc["aleoAppMetadataReviewedAt"] == wbtc["aleoRemoteRouterReviewedAt"] == "2026-08-17" + + usdt = REG.route("hyperlane:aleo/usdt->ethereum/usdt").metadata + assert usdt["aleoTokenOwner"] == "aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z" + assert usdt["aleoTokenId"] == "8295938150000417034830036849466229528602563851235385582732969109393809606969field" + assert (usdt["aleoLocalDecimals"], usdt["aleoRemoteDecimals"], usdt["aleoProgramEdition"], usdt["aleoScale"]) == (6, 18, 1, "1000000000000") + assert usdt["aleoRemoteRouterRecipient"] == "[0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, 240u8, 68u8, 227u8, 63u8, 191u8, 49u8]" + assert usdt["aleoRemoteRouterGas"] == "68000" and usdt["aleoSampleTransferDestinationDomain"] == 56 + assert usdt["aleoSampleTransferSource"] == "https://explorer.provable.com/transaction/at19caeeee8v3xc4kfwen4tx89f0tnggrpjp0anrhq2ca3y82xr9q8qyz8a9r" + assert usdt["aleoHyperlaneConfigSource"] == "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/USDT/aleo-config.yaml" + + sol = REG.route("hyperlane:aleo/sol->solana/sol").metadata + assert sol["aleoTokenOwner"] == "aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45" + assert sol["aleoTokenId"] == "6148061383892805373029428966764338809222769879628268522058032128225601478383field" + assert (sol["aleoLocalDecimals"], sol["aleoRemoteDecimals"], sol["aleoDestinationDomain"]) == (9, 9, 1399811149) + assert sol["aleoRemoteRouterSolanaAddress"] == "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + assert sol["aleoRemoteRouterRecipient"] == "[112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, 21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, 143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8]" + assert sol["aleoRemoteRouterGas"] == "300000" + assert sol["aleoSampleTransitionId"] == "au15fg39h53h55tkj0nexrme3k6pvgxngxapcyajdhf06jcg3cyeugq5kd7hg" + assert "routerAddress" not in sol # no Ethereum common block on the SOL withdrawal + + +def test_solana_deposit_route_metadata(): + r = REG.route("hyperlane:solana/sol->aleo/sol") + assert r.active and r.deployment_id == "SOL/aleo" + expected = { + "warpProgramAddress": "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", "tokenPda": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "nativeCollateralPda": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", "dispatchAuthorityPda": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "mailboxProgramAddress": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", "mailboxOutboxPda": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "igpProgramAddress": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", "igpProgramDataPda": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "igpAccount": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", "igpOverheadAccount": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "splNoopProgramAddress": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", "destinationDomain": 1634493807, + "destinationGasAmount": "464000", "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", "solanaReviewedAt": "2026-08-31", + "solanaConfigSource": "https://github.com/hyperlane-xyz/hyperlane-registry/blob/418056e21734d26a7d14692e0ec5e902cc9e86bf/deployments/warp_routes/SOL/aleo-config.yaml", + } + for k, v in {**expected, **MAILBOX}.items(): + assert r.metadata[k] == v, k + + +def test_metadata_required_routes(): + pairs = [("aleo/aleo", "ethereum/aleo"), ("aleo/aleo", "solana/aleo"), ("aleo/aleo", "base/aleo"), ("aleo/aleo", "hyperevm/aleo")] + for left, right in pairs: + for rid in (f"hyperlane:{left}->{right}", f"hyperlane:{right}->{left}"): + r = REG.route(rid) + assert r.availability == "metadata-required" and not r.active and r.deployment_id == "ALEO/aleo" + assert dict(r.metadata) == MAILBOX | { + "aleoHookManagerProgram": "hyp_hook_manager.aleo", + "aleoHookManagerProgramSource": "https://explorer.provable.com/program/hyp_hook_manager.aleo", + "aleoMailboxProgramSource": "https://explorer.provable.com/program/hyp_mailbox.aleo", + "aleoMailboxMetadataSource": "https://api.explorer.provable.com/v2/mainnet/program/hyp_mailbox.aleo/mapping/mailbox/true", + "aleoMailboxMetadataReviewedAt": "2026-08-17", + } + usad_in = REG.route("hyperlane:ethereum/usad->aleo/usad") + assert usad_in.availability == "metadata-required" and usad_in.deployment_id == "USAD/aleo" + usad_out = REG.route("hyperlane:aleo/usad->ethereum/usad") + m = usad_out.metadata + assert usad_out.availability == "metadata-required" and m["aleoPlaceholderConfiguration"] is True + assert m["aleoRouterProgram"] == "hyp_warp_token_usad_v2.aleo" and m["aleoDestinationDomain"] == 1 + assert m["aleoTokenType"] == "0" and m["aleoTokenId"] == "0field" and m["aleoRemoteRouterGas"] == "0" + assert m["aleoTokenOwner"] == m["aleoIsm"] == m["aleoHook"] == "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" + assert m["aleoRemoteRouterRecipient"] == "[" + ", ".join(["0u8"] * 32) + "]" and m["aleoRecipient"] == "[0u128, 0u128]" + assert all(m[f"aleoAllowanceAmount{i}"] == "0" for i in range(4)) + + +def test_route_filters_and_find_route(): + assert [r.id for r in REG.routes(source="aleo", protocol="xreserve")] == ["xreserve:aleo/usdcx->ethereum/usdc"] + assert [r.id for r in REG.routes(source="aleo/wbtc")] == ["hyperlane:aleo/wbtc->ethereum/wbtc"] + assert [r.id for r in REG.routes(destination="aleo", symbol="wbtc")] == ["hyperlane:ethereum/wbtc->aleo/wbtc"] + assert len(REG.routes(environment="testnet")) == 2 and len(REG.routes(environment="mainnet")) == 20 + assert len(REG.routes(source="solana")) == 2 # SOL deposit + metadata-required ALEO + assert REG.find_route("aleo/wbtc", "ethereum/wbtc").id == "hyperlane:aleo/wbtc->ethereum/wbtc" + assert REG.find_route(("ethereum", "usdc"), ("aleo", "usdcx"), protocol="xreserve").id == "xreserve:ethereum/usdc->aleo/usdcx" + assert REG.find_route("aleo/usad", "ethereum/usad").availability == "metadata-required" # visible, refused later + with pytest.raises(RouteNotFoundError): + REG.find_route("aleo/wbtc", "solana/sol") + with pytest.raises(RouteNotFoundError): + REG.find_route("aleo/wbtc", "ethereum/wbtc", protocol="xreserve") + with pytest.raises(RouteNotFoundError): + REG.route("hyperlane:aleo/doge->ethereum/doge") + # A synthetic duplicate pair across protocols is ambiguous without protocol= + dup = Route("xreserve:aleo/wbtc->ethereum/wbtc", "xreserve", "mainnet", "aleo/wbtc", "ethereum/wbtc", "active", None, None, {}) + reg2 = Registry(REG.version, REG.chains(), REG.assets(), [*REG.routes(include_unavailable=True), dup]) + with pytest.raises(AmbiguousRouteError): + reg2.find_route("aleo/wbtc", "ethereum/wbtc") + assert reg2.find_route("aleo/wbtc", "ethereum/wbtc", protocol="hyperlane").protocol == "hyperlane" + # disabled routes are hidden from routes() and find_route() unless include_unavailable + off = Route("hyperlane:aleo/eth->ethereum/eth", "hyperlane", "mainnet", "aleo/eth", "ethereum/eth", "disabled", None, None, {}) + reg3 = Registry(REG.version, REG.chains(), REG.assets(), [off]) + assert reg3.routes() == [] and reg3.routes(include_unavailable=True) == [off] + with pytest.raises(RouteNotFoundError): + reg3.find_route("aleo/eth", "ethereum/eth") + + +def test_route_meta_helpers(): + r = REG.route("hyperlane:aleo/eth->ethereum/eth") + assert r.meta_str("aleoRouterProgram") == "hyp_warp_token_eth_v2.aleo" + assert r.meta_int("aleoDestinationDomain") == 1 and r.meta_int("missing", 7) == 7 + with pytest.raises(ConfigurationError, match="aleoNope is missing"): + r.meta_str("aleoNope") + with pytest.raises(ConfigurationError, match="invalid"): + r.meta_int("aleoMailboxStateVerified") # bool is not an int here + + +def _with(**overrides) -> Registry: + base = dict(version=REG.version, chains=REG.chains(), assets=REG.assets(), routes=REG.routes(include_unavailable=True)) + base.update(overrides) + return Registry(base["version"], base["chains"], base["assets"], base["routes"]) + + +def test_validation_failures(): + from dataclasses import replace + r0 = REG.routes(include_unavailable=True)[0] + with pytest.raises(ConfigurationError, match="unknown source asset missing/asset"): + validate_registry(_with(routes=[replace(r0, source_asset_id="missing/asset")])) + with pytest.raises(ConfigurationError, match="Duplicate bridge route id"): + validate_registry(_with(routes=[r0, r0])) + a0 = REG.assets()[0] + with pytest.raises(ConfigurationError, match="Duplicate bridge asset key"): + validate_registry(_with(assets=[a0, replace(a0, id=a0.id + "-duplicate")], routes=[])) + with pytest.raises(ConfigurationError, match="invalid address validation regex"): + validate_registry(_with(assets=[replace(a0, address_regex="[")], routes=[])) + usdc = REG.asset("ethereum/usdc") + with pytest.raises(ConfigurationError, match="privacy capability on a non-Aleo chain"): + validate_registry(_with(assets=[replace(a, privacy=Privacy("arc20", "arc20_usdc.aleo")) if a.id == usdc.id else a for a in REG.assets()])) + with pytest.raises(ConfigurationError, match="empty privacy program"): + validate_registry(_with(assets=[replace(a, privacy=Privacy("arc20", "")) if a.id == "aleo/sol" else a for a in REG.assets()])) + with pytest.raises(ConfigurationError, match="crosses registry environments"): + validate_registry(_with(routes=[replace(r0, environment="testnet")])) + with pytest.raises(ConfigurationError, match="missing required Solana Hyperlane metadata"): + sol = REG.route("hyperlane:solana/sol->aleo/sol") + incomplete = {k: v for k, v in sol.metadata.items() if k != "igpAccount"} + validate_registry(_with(routes=[replace(r, metadata=incomplete) if r.id == sol.id else r for r in REG.routes(include_unavailable=True)])) + with pytest.raises(ConfigurationError, match="Duplicate bridge chain id"): + validate_registry(_with(chains=[*REG.chains(), REG.chain("aleo")])) + with pytest.raises(ConfigurationError, match="version must not be empty"): + validate_registry(_with(version=" ")) + + +def test_data_module_is_plain_literals(): + assert data.REGISTRY_VERSION == REG.version + assert len(data.CHAINS) == 7 and len(data.ASSETS) == 19 and len(data.ROUTES) == 22 + assert all(isinstance(c, dict) for c in data.CHAINS) and all(isinstance(r["metadata"], dict) for r in data.ROUTES) + assert re.compile(data.ALEO_ADDRESS).match("aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n") From ece5668ca5f7e50bb5900ba04927a574b43b637c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:41:18 -0400 Subject: [PATCH 05/94] feat(bridge-sdk): status enum, plan/receipt/progress and result dataclasses --- bridge-sdk/python/aleo_bridge/__init__.py | 4 + bridge-sdk/python/aleo_bridge/types.py | 255 ++++++++++++++++++++++ bridge-sdk/tests/test_types.py | 84 +++++++ 3 files changed, 343 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/types.py create mode 100644 bridge-sdk/tests/test_types.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index e74a5d23..084c7926 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -15,6 +15,8 @@ RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, ) from .registry import DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, validate_registry # noqa: E402 +from .types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, Fee, GasQuote, # noqa: E402 + MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, Status, Step, to_progress) __all__ = [ "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", @@ -23,4 +25,6 @@ "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", "UnsupportedRouteError", "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", + "Attestation", "BridgeStatus", "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "Fee", "GasQuote", + "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "Status", "Step", "to_progress", ] diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py new file mode 100644 index 00000000..96086200 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -0,0 +1,255 @@ +"""Typed results shared by every module (contract §types.py). Atomic amounts are ``int``; human amounts are ``str``.""" +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class Status(str, Enum): + PREPARED = "PREPARED" + SOURCE_APPROVAL_PENDING = "SOURCE_APPROVAL_PENDING" + SOURCE_SUBMISSION_PENDING = "SOURCE_SUBMISSION_PENDING" + SOURCE_CONFIRMING = "SOURCE_CONFIRMING" + ATTESTATION_PENDING = "ATTESTATION_PENDING" + DESTINATION_ACTION_REQUIRED = "DESTINATION_ACTION_REQUIRED" + DELIVERY_PENDING = "DELIVERY_PENDING" + DESTINATION_CONFIRMING = "DESTINATION_CONFIRMING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + EXPIRED = "EXPIRED" + + def __str__(self) -> str: # json.dumps and f-strings print the bare name + return self.value + + +TERMINAL = {Status.COMPLETED, Status.FAILED, Status.EXPIRED} +CALLER_BOUNDARIES = {Status.SOURCE_SUBMISSION_PENDING, Status.DESTINATION_ACTION_REQUIRED, + Status.COMPLETED, Status.FAILED, Status.EXPIRED} + +# brief §2.5 +_NEXT_BY_STATUS = { + Status.SOURCE_SUBMISSION_PENDING: "resume", + Status.DESTINATION_ACTION_REQUIRED: "complete", + Status.COMPLETED: "done", + Status.FAILED: "failed", + Status.EXPIRED: "failed", +} + + +@dataclass(frozen=True) +class Step: + id: str + kind: str # approve|deposit|burn|dispatch|wait-attestation|mint|withdraw|wait-delivery|confirm-delivery + executor: str # aleo-wallet|evm-wallet|solana-wallet|protocol + irreversible: bool + + +@dataclass(frozen=True) +class Fee: + kind: str + chain_id: str + asset_id: str + amount: str + estimated: bool + + +@dataclass(frozen=True) +class Plan: + route_id: str + registry_version: str + protocol: str + environment: str + source_asset_id: str + destination_asset_id: str + amount: str + amount_atomic: int + recipient: str + sender: str | None + mint_mode: str # "public" | "record" | "private" + steps: tuple[Step, ...] + + def to_dict(self) -> dict[str, Any]: + d = dataclasses.asdict(self) + d["steps"] = [dataclasses.asdict(s) for s in self.steps] + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "Plan": + data = dict(d) + data["steps"] = tuple(Step(**s) for s in data.get("steps", ())) + return cls(**data) + + +@dataclass(frozen=True) +class Quote: + kind: str # evm-hyperlane | solana-hyperlane | aleo-hyperlane | evm-xreserve | aleo-xreserve + plan: Plan + fees: tuple[Fee, ...] + amount_out: str | None + + +@dataclass(frozen=True) +class EvmHyperlaneQuote(Quote): + recipient_bytes32: bytes + native_value_atomic: int + native_fee_atomic: int + approval_required: bool | None + + +@dataclass(frozen=True) +class SolanaHyperlaneQuote(Quote): + igp_lamports: int + network_fee_lamports: int + rent_lamports: int + total_lamports: int + unique_message_address: str + + +@dataclass(frozen=True) +class AleoHyperlaneQuote(Quote): + gas_limit: int + gas_overhead: int + gas_price: int + exchange_rate: int + payment_microcredits: int + + +@dataclass(frozen=True) +class EvmXReserveQuote(Quote): + hook_data: bytes + remote_recipient_bytes32: bytes + balance_atomic: int + allowance_atomic: int + approval_required: bool + max_fee_atomic: int + + +@dataclass(frozen=True) +class AleoXReserveQuote(Quote): + withdrawal_fee_atomic: int + + +@dataclass +class Receipt: + id: str + protocol: str + status: Status + source_tx_id: str | None = None + destination_tx_id: str | None = None + protocol_state: dict[str, Any] = field(default_factory=dict) # MUST include "routeId" + next_action: dict[str, Any] | None = None # {"kind": "xreserve-private-mint", "chainId": ...} + + def __post_init__(self) -> None: + self.status = Status(self.status) + + def replace(self, **changes: Any) -> "Receipt": + return dataclasses.replace(self, **changes) + + +@dataclass(frozen=True) +class Progress: + next: str # "wait" | "resume" | "complete" | "done" | "failed" + plan: Plan + receipt: Receipt + error: str | None = None + + +def to_progress(plan: Plan, receipt: Receipt) -> Progress: + """brief §2.5: SOURCE_SUBMISSION_PENDING→resume, DESTINATION_ACTION_REQUIRED→complete, COMPLETED→done, + FAILED|EXPIRED→failed, everything else→wait.""" + if "routeId" not in receipt.protocol_state: + raise ValueError("Receipt.protocol_state must carry routeId") + return Progress(_NEXT_BY_STATUS.get(Status(receipt.status), "wait"), plan, receipt) + + +@dataclass(frozen=True) +class GasQuote: + route_id: str + gas_limit: int + gas_overhead: int + gas_price: int + exchange_rate: int + payment_microcredits: int + + +@dataclass(frozen=True) +class Attestation: + payload: bytes # 305 bytes + message_hash: bytes # 32 bytes + attestation: bytes # 65 bytes + status: str # "complete" + + +@dataclass(frozen=True) +class DispatchReceipt: + transaction_id: str + route_id: str + message_id: str | None + amount_atomic: int + receipt: Receipt + + +@dataclass(frozen=True) +class BurnReceipt: + transaction_id: str + route_id: str + mode: str + amount_atomic: int + receipt: Receipt + + +@dataclass(frozen=True) +class MintReceipt: + transaction_id: str + route_id: str + receipt: Receipt + + +@dataclass(frozen=True) +class DepositReceipt: + transaction_id: str + route_id: str + message_hash: str + nonce: str + receipt: Receipt + + +@dataclass(frozen=True) +class PrivacyReceipt: + transaction_id: str + asset_id: str + amount: str + amount_atomic: int + direction: str # "shield" | "unshield" + + +@dataclass(frozen=True) +class PreparedTx: + transaction_id: str + serialized: str # Transaction JSON; rebroadcast via network.submit_transaction(serialized) + + +@dataclass +class ChainStatus: + chain_id: str + address: str | None + can_sign: bool + balances: dict[str, int] # asset_id -> atomic + + +@dataclass +class BridgeStatus: + environment: str + registry_version: str + chains: list[ChainStatus] + pending: list[Progress] + + +__all__ = [ + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", +] diff --git a/bridge-sdk/tests/test_types.py b/bridge-sdk/tests/test_types.py new file mode 100644 index 00000000..be0aa315 --- /dev/null +++ b/bridge-sdk/tests/test_types.py @@ -0,0 +1,84 @@ +import dataclasses +import json + +import pytest + +from aleo_bridge.types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, Attestation, BridgeStatus, + ChainStatus, DispatchReceipt, Fee, GasQuote, Plan, PreparedTx, PrivacyReceipt, + Progress, Receipt, Status, Step, to_progress) + + +def _plan() -> Plan: + return Plan(route_id="hyperlane:aleo/wbtc->ethereum/wbtc", registry_version="2026-08-31.solana-deposits.1", + protocol="hyperlane", environment="mainnet", source_asset_id="aleo/wbtc", + destination_asset_id="ethereum/wbtc", amount="0.001", amount_atomic=100_000, + recipient="0x0000000000000000000000000000000000000001", sender=None, mint_mode="public", + steps=(Step("source-dispatch", "dispatch", "aleo-wallet", True), + Step("message-delivery", "wait-delivery", "protocol", False))) + + +def test_status_enum_and_sets(): + assert [s.value for s in Status] == [ + "PREPARED", "SOURCE_APPROVAL_PENDING", "SOURCE_SUBMISSION_PENDING", "SOURCE_CONFIRMING", "ATTESTATION_PENDING", + "DESTINATION_ACTION_REQUIRED", "DELIVERY_PENDING", "DESTINATION_CONFIRMING", "COMPLETED", "FAILED", "EXPIRED"] + assert Status.COMPLETED == "COMPLETED" and Status("FAILED") is Status.FAILED + assert TERMINAL == {Status.COMPLETED, Status.FAILED, Status.EXPIRED} + assert CALLER_BOUNDARIES == {Status.SOURCE_SUBMISSION_PENDING, Status.DESTINATION_ACTION_REQUIRED, + Status.COMPLETED, Status.FAILED, Status.EXPIRED} + assert json.dumps({"s": Status.COMPLETED}) == '{"s": "COMPLETED"}' + + +@pytest.mark.parametrize("status,expected", [ + (Status.SOURCE_SUBMISSION_PENDING, "resume"), (Status.DESTINATION_ACTION_REQUIRED, "complete"), + (Status.COMPLETED, "done"), (Status.FAILED, "failed"), (Status.EXPIRED, "failed"), + (Status.PREPARED, "wait"), (Status.SOURCE_APPROVAL_PENDING, "wait"), (Status.SOURCE_CONFIRMING, "wait"), + (Status.ATTESTATION_PENDING, "wait"), (Status.DELIVERY_PENDING, "wait"), (Status.DESTINATION_CONFIRMING, "wait"), +]) +def test_to_progress_table(status, expected): + receipt = Receipt(id="at1x", protocol="hyperlane", status=status, protocol_state={"routeId": "r"}) + plan = _plan() + progress = to_progress(plan, receipt) + assert progress.next == expected + assert progress.plan is plan and progress.receipt is receipt and progress.error is None + + +def test_to_progress_accepts_status_strings_and_requires_route_id(): + receipt = Receipt(id="at1x", protocol="hyperlane", status="COMPLETED", protocol_state={"routeId": "r"}) + assert to_progress(_plan(), receipt).next == "done" + with pytest.raises(ValueError, match="routeId"): + to_progress(_plan(), Receipt(id="at1x", protocol="hyperlane", status=Status.COMPLETED)) + + +def test_plan_round_trip(): + plan = _plan() + d = plan.to_dict() + assert d["steps"][0] == {"id": "source-dispatch", "kind": "dispatch", "executor": "aleo-wallet", "irreversible": True} + assert Plan.from_dict(json.loads(json.dumps(d))) == plan + with pytest.raises(dataclasses.FrozenInstanceError): + plan.amount = "2" # type: ignore[misc] + + +def test_receipt_replace_and_defaults(): + r = Receipt(id="0xabc", protocol="xreserve", status=Status.ATTESTATION_PENDING, protocol_state={"routeId": "x"}) + assert r.source_tx_id is None and r.destination_tx_id is None and r.next_action is None + r2 = r.replace(status=Status.DESTINATION_ACTION_REQUIRED, next_action={"kind": "xreserve-private-mint", "chainId": "aleo"}) + assert r2.status is Status.DESTINATION_ACTION_REQUIRED and r.status is Status.ATTESTATION_PENDING + assert r2.protocol_state == {"routeId": "x"} and r2.next_action["kind"] == "xreserve-private-mint" + + +def test_result_dataclasses(): + receipt = Receipt(id="at1x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, protocol_state={"routeId": "r"}) + dr = DispatchReceipt(transaction_id="at1x", route_id="r", message_id=None, amount_atomic=1, receipt=receipt) + assert dr.receipt.protocol_state["routeId"] == "r" + assert GasQuote("r", 44000, 159337, 1000000000, 402, 8174147).payment_microcredits == 8174147 + att = Attestation(payload=bytes(305), message_hash=bytes(32), attestation=bytes(65), status="complete") + assert len(att.payload) == 305 + assert PreparedTx("at1x", "{}").serialized == "{}" + assert PrivacyReceipt("at1s", "aleo/eth", "0.000000000000000001", 1, "shield").direction == "shield" + q = AleoHyperlaneQuote(kind="aleo-hyperlane", plan=_plan(), fees=(Fee("hook", "aleo", "aleo/aleo", "8.174147", False),), + amount_out="0.001", gas_limit=44000, gas_overhead=159337, gas_price=1000000000, + exchange_rate=402, payment_microcredits=8174147) + assert q.fees[0].estimated is False and q.kind == "aleo-hyperlane" + status = BridgeStatus(environment="mainnet", registry_version="v", chains=[ChainStatus("aleo", None, False, {})], pending=[]) + assert status.chains[0].can_sign is False + assert isinstance(Progress("wait", _plan(), receipt), Progress) From de45ad83e9cee123dbbe942fb0296d01694cfea8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:46:56 -0400 Subject: [PATCH 06/94] fix(bridge-sdk): derive Progress.error for failed transfers and export all types --- bridge-sdk/python/aleo_bridge/__init__.py | 14 ++++++--- bridge-sdk/python/aleo_bridge/types.py | 14 +++++++-- bridge-sdk/tests/test_types.py | 35 ++++++++++++++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 084c7926..09c4a103 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -15,8 +15,12 @@ RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError, ) from .registry import DEFAULT_REGISTRY, Asset, Chain, Locator, Privacy, Registry, Route, validate_registry # noqa: E402 -from .types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, Fee, GasQuote, # noqa: E402 - MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, Status, Step, to_progress) +from .types import ( # noqa: E402 + CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, BridgeStatus, BurnReceipt, + ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, GasQuote, + MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, + to_progress, +) __all__ = [ "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", @@ -25,6 +29,8 @@ "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", "UnsupportedRouteError", "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", - "Attestation", "BridgeStatus", "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "Fee", "GasQuote", - "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "Status", "Step", "to_progress", + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", ] diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py index 96086200..1054b447 100644 --- a/bridge-sdk/python/aleo_bridge/types.py +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -158,10 +158,20 @@ class Progress: def to_progress(plan: Plan, receipt: Receipt) -> Progress: """brief §2.5: SOURCE_SUBMISSION_PENDING→resume, DESTINATION_ACTION_REQUIRED→complete, COMPLETED→done, - FAILED|EXPIRED→failed, everything else→wait.""" + FAILED|EXPIRED→failed, everything else→wait. For FAILED|EXPIRED, derive error from protocol_state.""" if "routeId" not in receipt.protocol_state: raise ValueError("Receipt.protocol_state must carry routeId") - return Progress(_NEXT_BY_STATUS.get(Status(receipt.status), "wait"), plan, receipt) + status = Status(receipt.status) + next_val = _NEXT_BY_STATUS.get(status, "wait") + + # Derive error for terminal failure states + error = None + if status in {Status.FAILED, Status.EXPIRED}: + error = (receipt.protocol_state.get("destinationError") or + receipt.protocol_state.get("sourceError") or + f"Bridge transfer ended in {status.value}") + + return Progress(next_val, plan, receipt, error=error) @dataclass(frozen=True) diff --git a/bridge-sdk/tests/test_types.py b/bridge-sdk/tests/test_types.py index be0aa315..47df0597 100644 --- a/bridge-sdk/tests/test_types.py +++ b/bridge-sdk/tests/test_types.py @@ -3,6 +3,8 @@ import pytest +import aleo_bridge +from aleo_bridge import types from aleo_bridge.types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, Attestation, BridgeStatus, ChainStatus, DispatchReceipt, Fee, GasQuote, Plan, PreparedTx, PrivacyReceipt, Progress, Receipt, Status, Step, to_progress) @@ -39,7 +41,32 @@ def test_to_progress_table(status, expected): plan = _plan() progress = to_progress(plan, receipt) assert progress.next == expected - assert progress.plan is plan and progress.receipt is receipt and progress.error is None + assert progress.plan is plan and progress.receipt is receipt + # Error is only set for FAILED and EXPIRED statuses + if status in {Status.FAILED, Status.EXPIRED}: + assert progress.error is not None + else: + assert progress.error is None + + +def test_to_progress_error_derivation(): + """Test error field is derived from protocol_state for FAILED/EXPIRED statuses.""" + plan = _plan() + + # FAILED with destinationError (takes priority over sourceError) + receipt_de = Receipt(id="at1x", protocol="hyperlane", status=Status.FAILED, + protocol_state={"routeId": "r", "destinationError": "dest err", "sourceError": "src err"}) + assert to_progress(plan, receipt_de).error == "dest err" + + # FAILED with only sourceError + receipt_se = Receipt(id="at1x", protocol="hyperlane", status=Status.FAILED, + protocol_state={"routeId": "r", "sourceError": "src err only"}) + assert to_progress(plan, receipt_se).error == "src err only" + + # EXPIRED with neither error field (generates default message) + receipt_expired = Receipt(id="at1x", protocol="hyperlane", status=Status.EXPIRED, protocol_state={"routeId": "r"}) + progress = to_progress(plan, receipt_expired) + assert progress.error == "Bridge transfer ended in EXPIRED" def test_to_progress_accepts_status_strings_and_requires_route_id(): @@ -82,3 +109,9 @@ def test_result_dataclasses(): status = BridgeStatus(environment="mainnet", registry_version="v", chains=[ChainStatus("aleo", None, False, {})], pending=[]) assert status.chains[0].can_sign is False assert isinstance(Progress("wait", _plan(), receipt), Progress) + + +def test_all_types_exported_from_package(): + """Ensure all names in types.__all__ are re-exported from aleo_bridge.__all__.""" + assert set(types.__all__) <= set(aleo_bridge.__all__), \ + f"Missing exports: {set(types.__all__) - set(aleo_bridge.__all__)}" From 4550d9406c217061e5cd2f7c2b3c1fb57100d782 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 17:51:29 -0400 Subject: [PATCH 07/94] feat(bridge-sdk): AleoCall verb ladder with checkpointable delegate and fake facade fixtures --- bridge-sdk/python/aleo_bridge/_calls.py | 190 +++++++++++++++++ bridge-sdk/tests/conftest.py | 267 ++++++++++++++++++++++++ bridge-sdk/tests/test_calls.py | 108 ++++++++++ 3 files changed, 565 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/_calls.py create mode 100644 bridge-sdk/tests/conftest.py create mode 100644 bridge-sdk/tests/test_calls.py diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py new file mode 100644 index 00000000..cc6682a3 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -0,0 +1,190 @@ +"""AleoCall — a facade BoundCall plus a typed result builder (contract §_calls.py). + +The caller picks the proving path; every path harvests the ROOT transition's outputs (the last +transition whose program/function match the call — Aleo orders child transitions first) so the typed +result is complete without waiting for confirmation. ``delegate(broadcast=False)`` is the +checkpointable path: prove on the DPS, hand back the serialized transaction, then ``submit_prepared`` +— where a duplicate-transaction response counts as success (invariant 3). +""" +from __future__ import annotations + +import json +from typing import Any, Callable, Generic, TypeVar + +from .errors import ConfigurationError +from .types import PreparedTx + +R = TypeVar("R") +_DUPLICATE_MARKERS = ("already exists", "duplicate") + + +def _network_module(aleo: Any) -> Any: + import aleo as aleo_pkg + return getattr(aleo_pkg, aleo.network_name) + + +def extract_tx_id(payload: Any) -> str: + """Transaction id from a DPS result payload (dict variants or a bare id string).""" + if isinstance(payload, str) and payload.strip(): + return payload.strip() + if isinstance(payload, dict): + tx = payload.get("transaction") + if isinstance(tx, dict) and tx.get("id"): + return str(tx["id"]) + for key in ("transaction_id", "transactionId", "id", "txid", "tx_id"): + if payload.get(key): + return str(payload[key]) + raise ValueError(f"Cannot find a transaction id in DPS payload: {payload!r}") + + +def payload_transitions(payload: Any) -> "list[dict[str, Any]] | None": + """Decoded transitions from a DPS payload that carries the whole transaction; None when it is id-only.""" + if not isinstance(payload, dict): + return None + return _transaction_transitions(payload.get("transaction")) + + +def _transaction_transitions(tx: Any) -> "list[dict[str, Any]] | None": + if not isinstance(tx, dict): + return None + transitions = (tx.get("execution") or {}).get("transitions") + if not isinstance(transitions, list): + return None + return [{"program": str(t.get("program")), "function": str(t.get("function")), "outputs": t.get("outputs", [])} + for t in transitions] + + +def output_values(outputs: Any) -> list[str]: + values: list[str] = [] + for out in outputs: + if isinstance(out, dict): + value = out.get("value") + values.append(value if isinstance(value, str) else str(value)) + else: + values.append(str(out)) + return values + + +def root_outputs(decoded: list[dict[str, Any]], program: str, function: str) -> list[str]: + """Output values of the LAST transition matching *program*/*function* (the root).""" + for entry in reversed(decoded): + if str(entry.get("program")) == program and str(entry.get("function")) == function: + return output_values(entry.get("outputs", [])) + return [] + + +def is_duplicate_submission(exc: BaseException) -> bool: + text = str(exc).lower() + return any(marker in text for marker in _DUPLICATE_MARKERS) + + +class AleoCall(Generic[R]): + """A prepared Aleo write. Nothing touches the network until a verb runs.""" + + def __init__(self, aleo: Any, bound: Any, build_result: Callable[[str, list[str]], R], *, + imports: "dict[str, str] | None" = None) -> None: + self._aleo = aleo + self._bound = bound + self._build = build_result + self._imports = dict(imports or {}) + self._imports_registered = False + + def __repr__(self) -> str: + return f"AleoCall({self.program_id}/{self.function_name}, inputs={self.inputs!r})" + + @property + def program_id(self) -> str: + return str(self._bound.program_id) + + @property + def function_name(self) -> str: + return str(self._bound.function_name) + + @property + def inputs(self) -> list[str]: + """The exact Aleo input literals as they will be submitted.""" + return list(self._bound.args) + + # ── import registration (program sources the process must know before authorizing) ── + def _register_imports(self) -> None: + if self._imports_registered or not self._imports: + return + process = self._aleo.process + net = _network_module(self._aleo) + for program_id, source in self._imports.items(): + if process.contains_program(net.ProgramID.from_string(program_id)): + continue + process.add_program(net.Program.from_source(source)) + self._imports_registered = True + + # ── verbs ── + def simulate(self, account: Any = None) -> Any: + """Local authorization — no proof, no network send; inspect outputs before spending.""" + self._register_imports() + return self._bound.simulate(account) + + def prove(self, account: Any = None, **fee: Any) -> PreparedTx: + """Prove locally, do NOT broadcast; returns the serialized transaction for checkpointing.""" + self._register_imports() + tx = self._bound.build_transaction(account, **fee) + return PreparedTx(transaction_id=str(tx.id), serialized=str(tx.raw)) + + def transact(self, account: Any = None, **fee: Any) -> R: + """Prove locally, harvest root outputs, broadcast, build the typed result.""" + self._register_imports() + tx = self._bound.build_transaction(account, **fee) + outputs = root_outputs(tx.decoded(), self.program_id, self.function_name) + self._aleo.network.submit_transaction(tx.raw) + return self._build(str(tx.id), outputs) + + def delegate(self, account: Any = None, *, wait: bool = True, wait_timeout: float = 180.0, + broadcast: bool = True, **fee: Any) -> R: + """Delegate proving to the DPS (fee master pays by default). + + ``broadcast=True``: the prover broadcasts; outputs come from the returned transaction, or after + waiting and fetching when the payload is id-only. ``broadcast=False``: ``delegate_prepared`` then + ``submit_prepared`` so the exact bytes exist locally before the network sees them. + """ + if not broadcast: + return self.submit_prepared(self.delegate_prepared(account, **fee), wait=wait, wait_timeout=wait_timeout) + self._register_imports() + payload = self._bound.delegate(account, broadcast=True, **fee) + tx_id = extract_tx_id(payload) + decoded = payload_transitions(payload) + if decoded is None: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + tx = self._aleo.network.get_transaction_object(tx_id) + decoded = [{"program": str(t.program_id), "function": str(t.function_name), "outputs": list(t.outputs())} + for t in tx.transitions()] + elif wait: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + return self._build(tx_id, root_outputs(decoded, self.program_id, self.function_name)) + + def delegate_prepared(self, account: Any = None, **fee: Any) -> PreparedTx: + """DPS proves with ``broadcast=False``; returns the serialized transaction for checkpointing.""" + self._register_imports() + payload = self._bound.delegate(account, broadcast=False, **fee) + tx = payload.get("transaction") if isinstance(payload, dict) else None + if not isinstance(tx, dict) or not tx.get("id"): + raise ConfigurationError( + "The delegated prover did not return the transaction body; cannot checkpoint an unbroadcast " + "transaction. Use delegate(broadcast=True) or prove() instead.") + return PreparedTx(transaction_id=str(tx["id"]), serialized=json.dumps(tx)) + + def submit_prepared(self, prepared: PreparedTx, *, wait: bool = True, wait_timeout: float = 180.0) -> R: + """Broadcast a prepared transaction; a duplicate-transaction rejection is success (idempotent rebroadcast).""" + try: + self._aleo.network.submit_transaction(prepared.serialized) + except Exception as exc: # noqa: BLE001 — the node's error type varies by transport + if not is_duplicate_submission(exc): + raise + if wait: + self._aleo.network.wait_for_transaction(prepared.transaction_id, timeout=wait_timeout) + try: + decoded = _transaction_transitions(json.loads(prepared.serialized)) or [] + except (TypeError, ValueError): + decoded = [] + return self._build(prepared.transaction_id, root_outputs(decoded, self.program_id, self.function_name)) + + +__all__ = ["AleoCall", "extract_tx_id", "is_duplicate_submission", "output_values", "payload_transitions", "root_outputs"] diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py new file mode 100644 index 00000000..2e63e6cf --- /dev/null +++ b/bridge-sdk/tests/conftest.py @@ -0,0 +1,267 @@ +"""Hermetic stand-ins for the aleo facade. Records every call so tests assert on exact inputs.""" +from __future__ import annotations + +import importlib +import json +from typing import Any + +import pytest + +SIGNER = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +IGP = "aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74" +IGP_KEY_ETH = f"{{ igp: {IGP}, destination: 1u32 }}" +IGP_KEY_SOL = f"{{ igp: {IGP}, destination: 1399811149u32 }}" +ETH_GAS_CONFIG = "{\n gas_overhead: 159337u128,\n exchange_rate: 402u128,\n gas_price: 1000000000u128\n}" +SOL_GAS_CONFIG = "{ gas_overhead: 200000u128, exchange_rate: 1000u128, gas_price: 50000000u128 }" +USDCX_RECORD = f"{{ owner: {SIGNER}.private, amount: 5000000u128.private, _nonce: 7group.public }}" +USDCX_RECORD_SMALL = f"{{ owner: {SIGNER}.private, amount: 100u128.private, _nonce: 8group.public }}" +DELIVERED_KEY = "{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }" +NULLIFIED_NONCE = bytes.fromhex("aa" * 32) +CHILD = {"program": "usdcx_stablecoin.aleo", "function": "transfer_private_to_public", "outputs": [{"value": "1field"}]} + + +def _source(program_id: str) -> str: + return f"program {program_id};\nfunction main:\n input r0 as u64.public;\n output r0 as u64.public;\n" + + +class FakeAccount: + def __init__(self, address: str = SIGNER) -> None: + self.address = address + self.private_key = "APrivateKey1zkpFake" + + +class FakeMapping: + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + + def get(self, key: Any) -> Any: + return self._values.get(str(key)) # None ≙ absent/null mapping entry + + +class FakeTransition: + def __init__(self, program: str, function: str, outputs: list) -> None: + self.program_id, self.function_name, self._outputs = program, function, outputs + + def outputs(self) -> list: + return list(self._outputs) + + +def _tx_json(tx_id: str, program: str, function: str, outputs: list) -> dict: + return {"id": tx_id, "type": "execute", "execution": {"transitions": [ + dict(CHILD), {"program": program, "function": function, "outputs": outputs}]}} + + +class FakeTx: + def __init__(self, tx_id: str, program: str, function: str, outputs: list) -> None: + self.id = tx_id + self._json = _tx_json(tx_id, program, function, outputs) + self.raw = json.dumps(self._json) # str(raw) is what the node accepts + + def transitions(self) -> list[FakeTransition]: + return [FakeTransition(t["program"], t["function"], t["outputs"]) for t in self._json["execution"]["transitions"]] + + def decoded(self) -> list[dict]: + return [{"program": t.program_id, "function": t.function_name, "outputs": t.outputs()} for t in self.transitions()] + + +class FakeBound: + def __init__(self, aleo: "FakeAleo", program_id: str, function_name: str, args: tuple) -> None: + self._aleo, self.program_id, self.function_name = aleo, program_id, function_name + self.args = [str(a) for a in args] + aleo.calls.append((program_id, function_name, list(self.args))) + + def simulate(self, account: Any = None) -> str: + self._aleo.simulated.append((self.program_id, self.function_name)) + return "simulated" + + def build_transaction(self, account: Any = None, **fee: Any) -> FakeTx: + self._aleo.fee_kwargs.append(dict(fee)) + return FakeTx("at1built", self.program_id, self.function_name, [{"value": "77field"}]) + + def delegate(self, account: Any = None, *, broadcast: bool = True, **fee: Any) -> dict: + self._aleo.delegated.append({"program": self.program_id, "function": self.function_name, "broadcast": broadcast, **fee}) + if self._aleo.delegate_returns_id_only: + return {"transaction_id": "at1delegated"} + return {"transaction": _tx_json("at1delegated", self.program_id, self.function_name, [{"value": "77field"}])} + + +class FakeFunctions: + def __init__(self, aleo: "FakeAleo", program_id: str) -> None: + self._aleo, self._program_id = aleo, program_id + + def __getitem__(self, name: str): + return lambda *args: FakeBound(self._aleo, self._program_id, name, args) + + __getattr__ = __getitem__ + + +class FakeProgram: + def __init__(self, aleo: "FakeAleo", program_id: str) -> None: + self.id = program_id + self.source = _source(program_id) + self.imports = list(aleo.imports.get(program_id, [])) + self.raw = ("raw", program_id) + self.functions = FakeFunctions(aleo, program_id) + self._mappings = aleo.mappings.get(program_id, {}) + + def mapping(self, name: str) -> FakeMapping: + return FakeMapping(self._mappings.get(name, {})) + + def mappings(self) -> list[str]: + return sorted(self._mappings) + + +class FakePrograms: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def get(self, program_id: str) -> FakeProgram: + self._aleo.fetched.append(program_id) + return FakeProgram(self._aleo, program_id) + + +class FakeRecords: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def find(self, account: Any = None, *, program: str | None = None, record: str | None = None, + unspent: bool = True, **_: Any) -> list[dict]: + self._aleo.record_queries.append({"program": program, "record": record, "unspent": unspent}) + return [dict(r) for r in self._aleo.record_rows if program is None or r.get("program") == program] + + +class FakeNetwork: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def submit_transaction(self, transaction: Any) -> str: + self._aleo.submitted.append(transaction) + if self._aleo.duplicate_on_submit: + raise RuntimeError("Transaction 'at1prepared' already exists in the ledger") + if isinstance(transaction, str): + return str(json.loads(transaction)["id"]) + return str(getattr(transaction, "id", "at1built")) + + def wait_for_transaction(self, tx_id: str, *, timeout: float = 45.0, poll_interval: float = 2.0) -> dict: + self._aleo.waited.append((tx_id, timeout)) + return {"status": "accepted"} + + def get_transaction_object(self, tx_id: str) -> FakeTx: + return FakeTx(tx_id, "hyp_warp_token_wbtc_v2.aleo", "transfer_remote", [{"value": "99field"}]) + + +class FakeProcess: + def __init__(self, aleo: "FakeAleo") -> None: + self._aleo = aleo + + def contains_program(self, program_id: Any) -> bool: + return str(program_id) in self._aleo.registered + + def add_program(self, program: Any) -> None: + self._aleo.registered.append(str(program[1]) if isinstance(program, tuple) else str(program)) + + +class FakeAleo: + """Facade stand-in: mappings keyed program → mapping → key; records; network; process; recorders.""" + + def __init__(self, mappings: dict | None = None, records: list[dict] | None = None, + network_name: str = "mainnet", default_account: Any = None, imports: dict | None = None) -> None: + self.network_name = network_name + self.default_account = FakeAccount() if default_account is None else default_account + self.mappings = mappings or {} + # ``records`` is the module (aleo.records.find); the rows it returns live in ``record_rows``. + self.record_rows = records if records is not None else [{"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD}] + self.imports = imports or {} + self.calls: list = [] + self.simulated: list = [] + self.delegated: list = [] + self.fee_kwargs: list = [] + self.submitted: list = [] + self.waited: list = [] + self.fetched: list = [] + self.registered: list = [] + self.record_queries: list = [] + self.duplicate_on_submit = False + self.delegate_returns_id_only = False + self.programs = FakePrograms(self) + self.records = FakeRecords(self) + self.record_provider = self.records + self.network = FakeNetwork(self) + self.process = FakeProcess(self) + + +class FakeNetModule: + """Stands in for aleo. inside AleoCall's import registration (no real parsing).""" + + class Program: + @staticmethod + def from_source(source: str): + return ("raw", source.split(";")[0].removeprefix("program ")) + + class ProgramID: + @staticmethod + def from_string(value: str) -> str: + return value + + +def default_mappings() -> dict: + return { + "hyp_hook_manager.aleo": {"destination_gas_configs": {IGP_KEY_ETH: ETH_GAS_CONFIG, IGP_KEY_SOL: SOL_GAS_CONFIG}}, + "hyp_mailbox.aleo": {"deliveries": {DELIVERED_KEY: "{ block_height: 1u32 }"}}, + "usdcx_bridge_v2.aleo": {"nullifier": {"[" + ",".join(f"{b}u8" for b in NULLIFIED_NONCE) + "]": "true"}}, + "credits.aleo": {"account": {SIGNER: "2392443u64"}}, + "usdcx_stablecoin.aleo": {"balances": {SIGNER: "1000000u128"}, "freeze_list": {}, "freeze_list_last_index": {}}, + "arc20_wbtc.aleo": {"balances": {SIGNER: "10000u128"}}, + "arc20_eth.aleo": {"balances": {}}, + } + + +@pytest.fixture +def fake_aleo(monkeypatch) -> FakeAleo: + monkeypatch.setattr("aleo_bridge._calls._network_module", lambda aleo: FakeNetModule) + return FakeAleo(mappings=default_mappings()) + + +class _BridgeStub: + """The five Bridge seams protocol modules use, until Task 11 wires the real Bridge into this fixture.""" + + def __init__(self, aleo: FakeAleo) -> None: + from aleo_bridge._calls import AleoCall + from aleo_bridge.registry import DEFAULT_REGISTRY + + self.aleo = aleo + self.registry = DEFAULT_REGISTRY + self.environment = self.network = aleo.network_name + self._AleoCall = AleoCall + self._programs: dict = {} + for attr, module, cls in (("hyperlane", "hyperlane", "HyperlaneModule"), ("xreserve", "xreserve", "XReserveModule"), + ("freezelist", "freezelist", "FreezeList"), ("privacy", "privacy", "PrivacyModule")): + try: + setattr(self, attr, getattr(importlib.import_module(f"aleo_bridge.{module}"), cls)(self)) + except ImportError: + setattr(self, attr, None) + + def aleo_address(self) -> str: + return str(self.aleo.default_account.address) + + def program(self, program_id: str): + if program_id not in self._programs: + self._programs[program_id] = self.aleo.programs.get(program_id) + return self._programs[program_id] + + def mapping_value(self, program_id: str, mapping: str, key: str) -> str | None: + value = self.program(program_id).mapping(mapping).get(key) + if value is None: + return None + text = str(value).strip().strip('"') + return None if text in ("", "null", "None") else text + + def _call(self, program_id: str, function: str, inputs: list[str], build_result): + program = self.program(program_id) + bound = program.functions[function](*inputs) + return self._AleoCall(self.aleo, bound, build_result, imports={program_id: program.source}) + + +@pytest.fixture +def bridge(fake_aleo) -> Any: + return _BridgeStub(fake_aleo) diff --git a/bridge-sdk/tests/test_calls.py b/bridge-sdk/tests/test_calls.py new file mode 100644 index 00000000..89f92d12 --- /dev/null +++ b/bridge-sdk/tests/test_calls.py @@ -0,0 +1,108 @@ +import json + +import pytest + +from aleo_bridge._calls import AleoCall, extract_tx_id, is_duplicate_submission, payload_transitions, root_outputs +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.types import PreparedTx + +PROGRAM, FN = "shielded_usdcx_wrapper.aleo", "private_burn" + + +def _call(fake_aleo, imports=None) -> AleoCall: + bound = fake_aleo.programs.get(PROGRAM).functions[FN]("a", "2500000u128") + return AleoCall(fake_aleo, bound, lambda tx_id, outs: (tx_id, outs), imports=imports) + + +def test_helpers(): + assert extract_tx_id("at1abc") == "at1abc" + assert extract_tx_id({"transaction": {"id": "at1x"}}) == "at1x" + assert extract_tx_id({"transaction_id": "at1y"}) == "at1y" + with pytest.raises(ValueError): + extract_tx_id({"nope": 1}) + decoded = [{"program": "tok.aleo", "function": "transfer", "outputs": [{"value": "999field"}]}, + {"program": "p.aleo", "function": "f", "outputs": [{"value": "77field"}]}, + {"program": "p.aleo", "function": "f", "outputs": ["78field"]}] + assert root_outputs(decoded, "p.aleo", "f") == ["78field"] # LAST matching transition is the root + assert root_outputs(decoded, "p.aleo", "g") == [] + assert payload_transitions({"transaction_id": "x"}) is None + assert payload_transitions({"transaction": {"execution": {"transitions": [{"program": "p", "function": "f", "outputs": []}]}}}) == \ + [{"program": "p", "function": "f", "outputs": []}] + assert is_duplicate_submission(RuntimeError("Transaction 'at1x' already exists in the ledger")) + assert is_duplicate_submission(RuntimeError("duplicate transaction")) + assert not is_duplicate_submission(RuntimeError("insufficient fee")) + + +def test_attributes_and_simulate(fake_aleo): + call = _call(fake_aleo) + assert (call.program_id, call.function_name, call.inputs) == (PROGRAM, FN, ["a", "2500000u128"]) + assert call.simulate() == "simulated" and fake_aleo.simulated == [(PROGRAM, FN)] + assert fake_aleo.submitted == [] and fake_aleo.delegated == [] + + +def test_prove_returns_prepared_tx_without_broadcast(fake_aleo): + prepared = _call(fake_aleo).prove(priority_fee=5) + assert isinstance(prepared, PreparedTx) and prepared.transaction_id == "at1built" + assert json.loads(prepared.serialized)["id"] == "at1built" + assert fake_aleo.fee_kwargs == [{"priority_fee": 5}] and fake_aleo.submitted == [] + + +def test_transact_harvests_root_outputs_then_broadcasts(fake_aleo): + tx_id, outputs = _call(fake_aleo).transact() + assert tx_id == "at1built" and outputs == ["77field"] + assert len(fake_aleo.submitted) == 1 and json.loads(fake_aleo.submitted[0])["id"] == "at1built" + + +def test_delegate_broadcast_uses_payload_transitions(fake_aleo): + tx_id, outputs = _call(fake_aleo).delegate(wait=False) + assert (tx_id, outputs) == ("at1delegated", ["77field"]) + assert fake_aleo.delegated[0]["broadcast"] is True and fake_aleo.waited == [] + _call(fake_aleo).delegate(wait_timeout=7.0) + assert fake_aleo.waited == [("at1delegated", 7.0)] + + +def test_delegate_falls_back_to_fetching_when_payload_is_id_only(fake_aleo): + fake_aleo.delegate_returns_id_only = True + bound = fake_aleo.programs.get("hyp_warp_token_wbtc_v2.aleo").functions["transfer_remote"]("x") + tx_id, outputs = AleoCall(fake_aleo, bound, lambda t, o: (t, o)).delegate(wait=False) + assert tx_id == "at1delegated" and outputs == ["99field"] # from get_transaction_object + assert fake_aleo.waited == [("at1delegated", 180.0)] # must wait before fetching + + +def test_delegate_prepared_and_submit_prepared(fake_aleo): + call = _call(fake_aleo) + prepared = call.delegate_prepared() + assert fake_aleo.delegated[-1]["broadcast"] is False and fake_aleo.submitted == [] + assert prepared.transaction_id == "at1delegated" and json.loads(prepared.serialized)["execution"]["transitions"] + tx_id, outputs = call.submit_prepared(prepared, wait=False) + assert (tx_id, outputs) == ("at1delegated", ["77field"]) and fake_aleo.submitted == [prepared.serialized] + + +def test_delegate_without_broadcast_is_prepare_then_submit(fake_aleo): + tx_id, outputs = _call(fake_aleo).delegate(broadcast=False, wait_timeout=9.0) + assert tx_id == "at1delegated" and outputs == ["77field"] + assert fake_aleo.delegated[-1]["broadcast"] is False and len(fake_aleo.submitted) == 1 + assert fake_aleo.waited == [("at1delegated", 9.0)] + + +def test_submit_prepared_treats_duplicate_as_success(fake_aleo): + call = _call(fake_aleo) + prepared = call.delegate_prepared() + fake_aleo.duplicate_on_submit = True + tx_id, outputs = call.submit_prepared(prepared) + assert tx_id == "at1delegated" and outputs == ["77field"] + + +def test_delegate_prepared_requires_transaction_payload(fake_aleo): + fake_aleo.delegate_returns_id_only = True + with pytest.raises(ConfigurationError, match="did not return the transaction"): + _call(fake_aleo).delegate_prepared() + + +def test_imports_are_registered_once_before_first_verb(fake_aleo): + call = _call(fake_aleo, imports={"token_registry.aleo": "program token_registry.aleo;", PROGRAM: "program shielded_usdcx_wrapper.aleo;"}) + assert fake_aleo.registered == [] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", PROGRAM] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", PROGRAM] # idempotent From 8e12bccd21b7b3c943433521b9b5b400d5a7dfbd Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 18:01:37 -0400 Subject: [PATCH 08/94] fix(bridge-sdk): narrow duplicate-broadcast detection and document confirmation timeouts --- bridge-sdk/python/aleo_bridge/_calls.py | 30 +++++++++++++++++++++---- bridge-sdk/tests/conftest.py | 4 ++++ bridge-sdk/tests/test_calls.py | 14 +++++++++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index cc6682a3..d1735c99 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -15,7 +15,7 @@ from .types import PreparedTx R = TypeVar("R") -_DUPLICATE_MARKERS = ("already exists", "duplicate") +_DUPLICATE_MARKER = "already exists" def _network_module(aleo: Any) -> Any: @@ -74,8 +74,12 @@ def root_outputs(decoded: list[dict[str, Any]], program: str, function: str) -> def is_duplicate_submission(exc: BaseException) -> bool: - text = str(exc).lower() - return any(marker in text for marker in _DUPLICATE_MARKERS) + """True only for a node's "already exists" rejection (an idempotent rebroadcast). + + Deliberately narrow: a message like "duplicate serial number" or "duplicate output id" is a + REAL double-spend failure and must propagate, not be swallowed as success. + """ + return _DUPLICATE_MARKER in str(exc).lower() class AleoCall(Generic[R]): @@ -144,6 +148,15 @@ def delegate(self, account: Any = None, *, wait: bool = True, wait_timeout: floa ``broadcast=True``: the prover broadcasts; outputs come from the returned transaction, or after waiting and fetching when the payload is id-only. ``broadcast=False``: ``delegate_prepared`` then ``submit_prepared`` so the exact bytes exist locally before the network sees them. + + When the payload is id-only, ``wait`` is effectively forced ``True`` regardless of what was + passed: the transaction body must be fetched after confirmation to harvest outputs, so a wait + happens either way in that branch. + + If ``wait`` (or the forced wait above) times out, ``aleo.facade.errors.TransactionConfirmationTimeout`` + propagates AFTER the transaction has already been broadcast by the DPS — the transaction id is + recoverable from the exception's ``tx_id`` attribute (or from re-deriving it) for later polling; + the transfer itself was not rolled back. """ if not broadcast: return self.submit_prepared(self.delegate_prepared(account, **fee), wait=wait, wait_timeout=wait_timeout) @@ -172,7 +185,16 @@ def delegate_prepared(self, account: Any = None, **fee: Any) -> PreparedTx: return PreparedTx(transaction_id=str(tx["id"]), serialized=json.dumps(tx)) def submit_prepared(self, prepared: PreparedTx, *, wait: bool = True, wait_timeout: float = 180.0) -> R: - """Broadcast a prepared transaction; a duplicate-transaction rejection is success (idempotent rebroadcast).""" + """Broadcast a prepared transaction; a duplicate-transaction rejection is success (idempotent rebroadcast). + + If ``wait`` is true and confirmation does not land within ``wait_timeout``, + ``aleo.facade.errors.TransactionConfirmationTimeout`` propagates AFTER the transaction has already + been broadcast (the ``submit_transaction`` call above already returned/succeeded) — this is not a + submission failure. The transaction id is recoverable from ``prepared.transaction_id`` or from the + exception's own ``tx_id`` attribute, for later polling or a checkpoint. Keeping this raise (rather + than swallowing it) is consistent with the rest of the facade; the lifecycle layer calls + ``submit_prepared(wait=False)`` and does its own status polling instead of relying on this wait. + """ try: self._aleo.network.submit_transaction(prepared.serialized) except Exception as exc: # noqa: BLE001 — the node's error type varies by transport diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 2e63e6cf..71748b40 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -144,6 +144,9 @@ def submit_transaction(self, transaction: Any) -> str: def wait_for_transaction(self, tx_id: str, *, timeout: float = 45.0, poll_interval: float = 2.0) -> dict: self._aleo.waited.append((tx_id, timeout)) + if self._aleo.wait_raises: + from aleo.facade.errors import TransactionConfirmationTimeout + raise TransactionConfirmationTimeout(tx_id, timeout) return {"status": "accepted"} def get_transaction_object(self, tx_id: str) -> FakeTx: @@ -183,6 +186,7 @@ def __init__(self, mappings: dict | None = None, records: list[dict] | None = No self.record_queries: list = [] self.duplicate_on_submit = False self.delegate_returns_id_only = False + self.wait_raises = False self.programs = FakePrograms(self) self.records = FakeRecords(self) self.record_provider = self.records diff --git a/bridge-sdk/tests/test_calls.py b/bridge-sdk/tests/test_calls.py index 89f92d12..e8027a81 100644 --- a/bridge-sdk/tests/test_calls.py +++ b/bridge-sdk/tests/test_calls.py @@ -29,7 +29,8 @@ def test_helpers(): assert payload_transitions({"transaction": {"execution": {"transitions": [{"program": "p", "function": "f", "outputs": []}]}}}) == \ [{"program": "p", "function": "f", "outputs": []}] assert is_duplicate_submission(RuntimeError("Transaction 'at1x' already exists in the ledger")) - assert is_duplicate_submission(RuntimeError("duplicate transaction")) + assert not is_duplicate_submission(RuntimeError("duplicate transaction")) # real double-spend, must propagate + assert not is_duplicate_submission(RuntimeError("duplicate serial number")) assert not is_duplicate_submission(RuntimeError("insufficient fee")) @@ -93,6 +94,17 @@ def test_submit_prepared_treats_duplicate_as_success(fake_aleo): assert tx_id == "at1delegated" and outputs == ["77field"] +def test_submit_prepared_confirmation_timeout_propagates_after_broadcast(fake_aleo): + from aleo.facade.errors import TransactionConfirmationTimeout + + call = _call(fake_aleo) + prepared = call.delegate_prepared() + fake_aleo.wait_raises = True + with pytest.raises(TransactionConfirmationTimeout): + call.submit_prepared(prepared) + assert fake_aleo.submitted == [prepared.serialized] # broadcast already happened + + def test_delegate_prepared_requires_transaction_payload(fake_aleo): fake_aleo.delegate_returns_id_only = True with pytest.raises(ConfigurationError, match="did not return the transaction"): From c3e18e95463c03feda664354544167f2ae98929c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 18:08:35 -0400 Subject: [PATCH 09/94] feat(bridge-sdk): Aleo-origin Hyperlane transfer_remote, IGP quote and delivery read --- bridge-sdk/python/aleo_bridge/hyperlane.py | 195 ++++++++++++++++++++ bridge-sdk/tests/test_hyperlane.py | 204 +++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/hyperlane.py create mode 100644 bridge-sdk/tests/test_hyperlane.py diff --git a/bridge-sdk/python/aleo_bridge/hyperlane.py b/bridge-sdk/python/aleo_bridge/hyperlane.py new file mode 100644 index 00000000..03d6cf19 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/hyperlane.py @@ -0,0 +1,195 @@ +"""Aleo-origin Hyperlane warp routes (port of veil protocols/hyperlane/aleo.ts and utils/hyperlaneDelivery.ts). + +Seven-input ``transfer_remote`` with allowance slot 0 = live IGP payment; IGP quote from +``hyp_hook_manager.aleo/destination_gas_configs``; delivery read from ``hyp_mailbox.aleo/deliveries``. +""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, Callable + +from . import encoding as enc +from ._calls import AleoCall +from .errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) +from .registry import Asset, Chain, Route +from .types import DispatchReceipt, GasQuote, Receipt, Status +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +MAX_U64 = (1 << 64) - 1 +GAS_QUOTE_SCALE = 10_000_000_000 # fixed by hyp_hook_manager.aleo post_dispatch +ZERO_GAS_LIMIT_FALLBACK = 50_000 +_GAS_FIELDS = ("gas_overhead", "exchange_rate", "gas_price") +_GAS_FIELD_RE = re.compile(r"\b(gas_overhead|exchange_rate|gas_price)\s*:\s*(\d+)u128") + + +def parse_gas_config(literal: str) -> dict[str, int]: + """``{ gas_overhead: 159337u128, exchange_rate: 402u128, gas_price: 1000000000u128 }`` → ints.""" + found = {m.group(1): int(m.group(2)) for m in _GAS_FIELD_RE.finditer(literal)} + missing = [f for f in _GAS_FIELDS if f not in found] + if missing: + raise ConfigurationError(f"Hyperlane destination gas configuration is malformed (missing {missing}): {literal!r}") + return found + + +def gas_config_key(route: Route) -> str: + """The ``destination_gas_configs`` key the hook manager reads at finalization.""" + return f"{{ igp: {route.meta_str('aleoMailboxDefaultHook')}, destination: {route.meta_int('aleoDestinationDomain')}u32 }}" + + +def compute_gas_payment(*, gas_limit: int, gas_overhead: int, gas_price: int, exchange_rate: int) -> int: + """Exact integer formula asserted on chain: ``(limit + overhead) * price * rate // 10^10`` as a positive u64.""" + payment = ((gas_limit + gas_overhead) * gas_price * exchange_rate) // GAS_QUOTE_SCALE + if payment <= 0 or payment > MAX_U64: + raise ConfigurationError(f"Hyperlane hook payment does not fit a positive u64: {payment}") + return payment + + +def _allowance(route: Route, index: int, amount: int | None) -> str: + value = str(amount) if amount is not None else route.meta_str(f"aleoAllowanceAmount{index}") + return f"{{ spender: {route.meta_str(f'aleoAllowanceSpender{index}')}, amount: {value}u64 }}" + + +class HyperlaneModule: + """``bridge.hyperlane`` — Aleo-side Hyperlane reads and the ``transfer_remote`` write.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + # ── route resolution ── + def _aleo_chain(self) -> Chain: + chains = [c for c in self._bridge.registry.chains(environment=self._bridge.environment) if c.family == "aleo"] + if len(chains) != 1: + raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self._bridge.environment}") + return chains[0] + + def _aleo_asset(self, asset: Any) -> Asset: + resolved = self._bridge.registry.asset(asset) + if resolved.chain_id != self._aleo_chain().id: + raise UnsupportedRouteError( + f"{resolved.id} is not an Aleo asset on {self._bridge.environment}; Aleo-origin Hyperlane transfers " + "start from aleo/eth, aleo/wbtc, aleo/usdt or aleo/sol (use bridge.eth / bridge.sol for other origins)") + return resolved + + def _route_for(self, asset_or_route: Any) -> Route: + if isinstance(asset_or_route, Route): + route = asset_or_route + if route.protocol != "hyperlane": + raise UnsupportedRouteError(f"Not a Hyperlane route: {route.id}") + self._aleo_asset(route.source_asset_id) + return route + return self.outbound_route(asset_or_route) + + def outbound_route(self, asset: Any) -> Route: + """The single active, non-placeholder Hyperlane route leaving this Aleo asset.""" + source = self._aleo_asset(asset) + candidates = [r for r in self._bridge.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self._bridge.environment) + if r.source_asset_id == source.id] + if not candidates: + raise RouteNotFoundError(f"No Hyperlane route leaves {source.id}") + executable = [r for r in candidates if r.active and r.metadata.get("aleoPlaceholderConfiguration") is not True] + if not executable: + detail = ", ".join(f"{r.id} ({r.availability})" for r in candidates) + raise RouteUnavailableError(f"Hyperlane routes from {source.id} are not executable: {detail}") + if len(executable) > 1: + raise AmbiguousRouteError(f"Several active Hyperlane routes leave {source.id}: {[r.id for r in executable]}") + return executable[0] + + # ── reads ── + def quote_gas_payment(self, asset: Any) -> GasQuote: + """Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving.""" + route = self._route_for(asset) + literal = self._bridge.mapping_value(route.meta_str("aleoHookManagerProgram"), "destination_gas_configs", + gas_config_key(route)) + if literal is None: + raise ConfigurationError(f"Hyperlane destination gas configuration is missing on chain: {route.id}") + config = parse_gas_config(literal) + if config["exchange_rate"] == 0 or config["gas_price"] == 0: + raise ConfigurationError(f"Hyperlane destination gas configuration is unpriced: {route.id}") + gas_limit = int(route.meta_str("aleoRemoteRouterGas")) or ZERO_GAS_LIMIT_FALLBACK + payment = compute_gas_payment(gas_limit=gas_limit, gas_overhead=config["gas_overhead"], + gas_price=config["gas_price"], exchange_rate=config["exchange_rate"]) + return GasQuote(route.id, gas_limit, config["gas_overhead"], config["gas_price"], config["exchange_rate"], payment) + + def _mailbox_program(self) -> str: + for route in self._bridge.registry.routes(protocol="hyperlane", include_unavailable=True, environment=self._bridge.environment): + program = route.metadata.get("aleoMailboxProgram") + if isinstance(program, str) and program: + return program + raise ConfigurationError(f"No Aleo Hyperlane mailbox program is configured for {self._bridge.environment}") + + def is_delivered(self, message_id: "str | bytes") -> bool: + """Whether ``hyp_mailbox.aleo/deliveries`` holds the message (mapping presence is the acceptance signal).""" + try: + raw = enc.hex_to_bytes(message_id, 32) + except ValueError as exc: + raise ConfigurationError("Hyperlane delivery requires a 32-byte message id") from exc + return self._bridge.mapping_value(self._mailbox_program(), "deliveries", enc.hyperlane_delivery_key(raw)) is not None + + # ── transfer_remote ── + def build_transfer_remote_inputs(self, route: Route, *, recipient: str, amount_atomic: int, + gas_payment_microcredits: int, decimals: tuple[int, int]) -> list[str]: + """The seven ``transfer_remote`` literals (brief §3.3). Pure; works for placeholder routes too (inspection only).""" + if isinstance(gas_payment_microcredits, bool) or not isinstance(gas_payment_microcredits, int) \ + or not (0 < gas_payment_microcredits <= MAX_U64): + raise ConfigurationError(f"gas_payment_microcredits must be a positive u64: {gas_payment_microcredits}") + if amount_atomic <= 0: + raise InvalidAmountError("Bridge transfer amount must be greater than zero") + registry = self._bridge.registry + destination = registry.asset(route.destination_asset_id) + destination_chain = registry.chain(destination.chain_id) + if not destination.matches_address(recipient): + raise InvalidRecipientError(f"Recipient does not match the {destination.chain_id} address format: {recipient}") + if destination_chain.family == "evm": + limbs = enc.evm_address_to_hyperlane_recipient(recipient) + elif destination_chain.family == "solana": + limbs = enc.solana_address_to_hyperlane_recipient(recipient) + else: + raise UnsupportedRouteError(f"Unsupported Hyperlane destination family {destination_chain.family!r}: {route.id}") + local_decimals, remote_decimals = decimals + domain = route.meta_int("aleoDestinationDomain") + app_metadata = (f"{{ token_type: {route.meta_str('aleoTokenType')}u8, token_owner: {route.meta_str('aleoTokenOwner')}, " + f"ism: {route.meta_str('aleoIsm')}, hook: {route.meta_str('aleoHook')}, " + f"token_id: {route.meta_str('aleoTokenId')}, local_decimals: {local_decimals}u8, " + f"remote_decimals: {remote_decimals}u8 }}") + mailbox_state = (f"{{ default_hook: {route.meta_str('aleoMailboxDefaultHook')}, " + f"required_hook: {route.meta_str('aleoMailboxRequiredHook')} }}") + remote_router = (f"{{ domain: {domain}u32, recipient: {route.meta_str('aleoRemoteRouterRecipient')}, " + f"gas: {route.meta_str('aleoRemoteRouterGas')}u128 }}") + allowances = "[" + ", ".join(_allowance(route, i, gas_payment_microcredits if i == 0 else None) for i in range(4)) + "]" + return [app_metadata, mailbox_state, remote_router, f"{domain}u32", enc.u128_pair_literal(limbs), + f"{amount_atomic}u128", allowances] + + def transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + as_signer: bool = False, gas_payment_microcredits: int | None = None) -> AleoCall[DispatchReceipt]: + """Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the + lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again.""" + route = self.outbound_route(asset) + registry = self._bridge.registry + source, destination = registry.asset(route.source_asset_id), registry.asset(route.destination_asset_id) + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + parse_decimal_amount(format_decimal_amount(atomic, source.decimals), destination.decimals) # veil prepare(): representable on both sides + payment = gas_payment_microcredits if gas_payment_microcredits is not None \ + else self.quote_gas_payment(route).payment_microcredits + decimals = (route.meta_int("aleoLocalDecimals", source.decimals), route.meta_int("aleoRemoteDecimals", destination.decimals)) + inputs = self.build_transfer_remote_inputs(route, recipient=recipient, amount_atomic=atomic, + gas_payment_microcredits=payment, decimals=decimals) + program = route.meta_str("aleoRouterProgram") + function = "transfer_remote_as_signer" if as_signer else "transfer_remote" + + def build(tx_id: str, _outputs: list[str]) -> DispatchReceipt: + receipt = Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": route.id, "sourceProgram": program, "sourceFunction": function, + "amountAtomic": str(atomic), "recipient": recipient, + "gasPaymentMicrocredits": str(payment)}) + return DispatchReceipt(transaction_id=tx_id, route_id=route.id, message_id=None, amount_atomic=atomic, receipt=receipt) + + return self._bridge._call(program, function, inputs, build) + + +__all__ = ["GAS_QUOTE_SCALE", "MAX_U64", "ZERO_GAS_LIMIT_FALLBACK", "HyperlaneModule", "compute_gas_payment", + "gas_config_key", "parse_gas_config"] diff --git a/bridge-sdk/tests/test_hyperlane.py b/bridge-sdk/tests/test_hyperlane.py new file mode 100644 index 00000000..5702e101 --- /dev/null +++ b/bridge-sdk/tests/test_hyperlane.py @@ -0,0 +1,204 @@ +import pytest + +from aleo_bridge import hyperlane as hl +from aleo_bridge.errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteUnavailableError, UnsupportedRouteError) +from aleo_bridge.registry import DEFAULT_REGISTRY as REG +from aleo_bridge.types import DispatchReceipt, Status +from tests.conftest import ETH_GAS_CONFIG, IGP_KEY_ETH + +EVM1 = "0x0000000000000000000000000000000000000001" +SOL_SYSTEM = "11111111111111111111111111111111" +ZERO = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +MAILBOX_STATE = ("{ default_hook: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, " + "required_hook: aleo1yxevh9qgxehej46j7vueplwjcpfdfml2dje3ey4ukzknx7wzasgqnxgq82 }") +ROUTES = [ # veil test/actions/aleoHyperlane.test.ts ROUTES (active four); amount "1" in source decimals + ("aleo/eth", "hyperlane:aleo/eth->ethereum/eth", "hyp_warp_token_eth_v2.aleo", EVM1, 10**18), + ("aleo/wbtc", "hyperlane:aleo/wbtc->ethereum/wbtc", "hyp_warp_token_wbtc_v2.aleo", EVM1, 10**8), + ("aleo/usdt", "hyperlane:aleo/usdt->ethereum/usdt", "hyp_warp_token_usdt_v2.aleo", EVM1, 10**6), + ("aleo/sol", "hyperlane:aleo/sol->solana/sol", "hyp_warp_token_sol_v2.aleo", SOL_SYSTEM, 10**9), +] + + +def _inputs(bridge, route_id, recipient, amount, gas=8174147): + route = REG.route(route_id) + src, dst = REG.asset(route.source_asset_id), REG.asset(route.destination_asset_id) + return bridge.hyperlane.build_transfer_remote_inputs( + route, recipient=recipient, amount_atomic=amount, gas_payment_microcredits=gas, + decimals=(route.metadata["aleoLocalDecimals"], route.metadata["aleoRemoteDecimals"])) + + +def test_pure_helpers(): + assert hl.parse_gas_config(ETH_GAS_CONFIG) == {"gas_overhead": 159337, "exchange_rate": 402, "gas_price": 1000000000} + with pytest.raises(ConfigurationError, match="malformed"): + hl.parse_gas_config("{ gas_overhead: 1u128 }") + assert hl.gas_config_key(REG.route("hyperlane:aleo/eth->ethereum/eth")) == IGP_KEY_ETH + assert hl.compute_gas_payment(gas_limit=44000, gas_overhead=159337, gas_price=1000000000, exchange_rate=402) == 8174147 + with pytest.raises(ConfigurationError, match="positive u64"): + hl.compute_gas_payment(gas_limit=0, gas_overhead=0, gas_price=1, exchange_rate=1) + with pytest.raises(ConfigurationError, match="positive u64"): + hl.compute_gas_payment(gas_limit=1, gas_overhead=0, gas_price=2**128 - 1, exchange_rate=2**128 - 1) + + +@pytest.mark.parametrize("asset,route_id,program,recipient,amount", ROUTES) +def test_outbound_route_and_common_shape(bridge, asset, route_id, program, recipient, amount): + route = bridge.hyperlane.outbound_route(asset) + assert route.id == route_id and route.meta_str("aleoRouterProgram") == program + inputs = _inputs(bridge, route_id, recipient, amount, gas=1) + assert len(inputs) == 7 + assert inputs[1] == MAILBOX_STATE + assert inputs[6].count("spender:") == 4 and inputs[6].count("amount: 0u64") == 3 and "amount: 1u64" in inputs[6] + assert inputs[6].startswith("[{ spender: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, amount: 1u64 }, { spender: " + ZERO) + assert inputs[5] == f"{amount}u128" + + +def test_eth_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/eth->ethereum/eth", EVM1, 10**18) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1wq6f6qdqya44avznygz5hae40u3mjg64w0r93a4qfu4utpf8cg9q566f4r, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 133188123661477349522757068766864658505569365361420630212878794317749195359field, " + "local_decimals: 18u8, remote_decimals: 18u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "56u8, 212u8, 71u8, 105u8, 79u8, 92u8, 31u8, 119u8, 58u8, 227u8, 19u8, 44u8, 249u8, 59u8, " + "243u8, 11u8, 126u8, 193u8, 250u8, 90u8], gas: 44000u128 }") + assert inputs[3] == "1u32" + assert inputs[4] == "[0u128, 1329227995784915872903807060280344576u128]" + assert inputs[5] == "1000000000000000000u128" + assert inputs[6] == ("[{ spender: aleo194tz0jmyq8rd9htvnqppqw4jqerk2p2zd8plzn3sxl06wcgsm5pq9fka74, amount: 8174147u64 }, " + f"{{ spender: {ZERO}, amount: 0u64 }}, {{ spender: {ZERO}, amount: 0u64 }}, {{ spender: {ZERO}, amount: 0u64 }}]") + + +def test_wbtc_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/wbtc->ethereum/wbtc", EVM1, 10**8) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo14jauje2a5sncm9u5t3mt6qqv3eq2hatkddskccs0dvsy35a0x58q0d6f95, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 1505227928464760254508513036497943623956572091841806589002910775534260084309field, " + "local_decimals: 8u8, remote_decimals: 8u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "32u8, 205u8, 200u8, 87u8, 120u8, 183u8, 50u8, 7u8, 63u8, 126u8, 236u8, 239u8, 61u8, 242u8, " + "92u8, 13u8, 49u8, 15u8, 135u8, 114u8], gas: 68000u128 }") + assert inputs[5] == "100000000u128" + + +def test_usdt_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/usdt->ethereum/usdt", EVM1, 10**6) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1l3gwacmjruxryy9c7c4fn0acyzprf29hucrvthw7f63lpyhd5y9srydq8z, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 8295938150000417034830036849466229528602563851235385582732969109393809606969field, " + "local_decimals: 6u8, remote_decimals: 18u8 }") + assert inputs[2] == ("{ domain: 1u32, recipient: [0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, " + "60u8, 32u8, 100u8, 215u8, 142u8, 69u8, 120u8, 232u8, 249u8, 54u8, 227u8, 219u8, 66u8, 174u8, " + "240u8, 68u8, 227u8, 63u8, 191u8, 49u8], gas: 68000u128 }") + assert inputs[5] == "1000000u128" + + +def test_sol_inputs_match_veil_vector(bridge): + inputs = _inputs(bridge, "hyperlane:aleo/sol->solana/sol", SOL_SYSTEM, 10**9) + assert inputs[0] == ("{ token_type: 1u8, token_owner: aleo1wr8rfr4ggedjxtg5e23s38zqkgy2j05uc9l8t4akjp5zcw3levpswkwk45, " + "ism: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "hook: aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc, " + "token_id: 6148061383892805373029428966764338809222769879628268522058032128225601478383field, " + "local_decimals: 9u8, remote_decimals: 9u8 }") + assert inputs[2] == ("{ domain: 1399811149u32, recipient: [112u8, 4u8, 72u8, 22u8, 219u8, 143u8, 68u8, 202u8, " + "21u8, 197u8, 236u8, 182u8, 198u8, 142u8, 52u8, 96u8, 142u8, 38u8, 51u8, 113u8, 116u8, " + "143u8, 96u8, 123u8, 104u8, 126u8, 97u8, 73u8, 7u8, 6u8, 211u8, 122u8], gas: 300000u128 }") + assert inputs[3] == "1399811149u32" and inputs[4] == "[0u128, 0u128]" and inputs[5] == "1000000000u128" + real = _inputs(bridge, "hyperlane:aleo/sol->solana/sol", "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7", 1) + assert real[4] == "[127878782877948140186055645953777992816u128, 163261512394675613100746600600636171918u128]" + + +def test_placeholder_usad_route_is_inspectable_but_not_executable(bridge): + usad = REG.route("hyperlane:aleo/usad->ethereum/usad") + inputs = bridge.hyperlane.build_transfer_remote_inputs(usad, recipient=EVM1, amount_atomic=1_000_000, + gas_payment_microcredits=1, decimals=(6, 6)) + assert inputs[3] == "1u32" and inputs[4] == "[0u128, 1329227995784915872903807060280344576u128]" and inputs[5] == "1000000u128" + assert "gas: 0u128" in inputs[2] and inputs[0].startswith("{ token_type: 0u8, token_owner: aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n") + with pytest.raises(RouteUnavailableError, match="not executable"): + bridge.hyperlane.outbound_route("aleo/usad") + with pytest.raises(RouteUnavailableError): + bridge.hyperlane.transfer_remote("aleo/usad", EVM1, amount="1") + with pytest.raises(RouteUnavailableError): # four metadata-required ALEO routes, none active + bridge.hyperlane.outbound_route("aleo/aleo") + with pytest.raises(UnsupportedRouteError, match="Aleo asset"): + bridge.hyperlane.outbound_route("ethereum/eth") + assert bridge.aleo.calls == [] + + +def test_input_validation(bridge): + route = REG.route("hyperlane:aleo/eth->ethereum/eth") + kw = dict(recipient=EVM1, amount_atomic=1, gas_payment_microcredits=1, decimals=(18, 18)) + for bad in (0, 1 << 64): + with pytest.raises(ConfigurationError, match="positive u64"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "gas_payment_microcredits": bad}) + with pytest.raises(InvalidAmountError, match="greater than zero"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "amount_atomic": 0}) + with pytest.raises(InvalidRecipientError, match="ethereum address format"): + bridge.hyperlane.build_transfer_remote_inputs(route, **{**kw, "recipient": "aleo1" + "a" * 58}) + with pytest.raises(InvalidRecipientError): + bridge.hyperlane.build_transfer_remote_inputs(REG.route("hyperlane:aleo/sol->solana/sol"), **{**kw, "recipient": EVM1, "decimals": (9, 9)}) + with pytest.raises(InvalidAmountError): + bridge.hyperlane.transfer_remote("aleo/eth", EVM1, amount="0.1234567890123456789") # 19 fractional digits + + +def test_quote_gas_payment_vector(bridge): + quote = bridge.hyperlane.quote_gas_payment("aleo/eth") + assert (quote.route_id, quote.gas_limit, quote.gas_overhead, quote.gas_price, quote.exchange_rate, quote.payment_microcredits) == \ + ("hyperlane:aleo/eth->ethereum/eth", 44000, 159337, 1000000000, 402, 8174147) + assert bridge.aleo.fetched.count("hyp_hook_manager.aleo") >= 1 + sol = bridge.hyperlane.quote_gas_payment("aleo/sol") # SOL_GAS_CONFIG: (300000+200000)*50000000*1000 // 10**10 + assert (sol.gas_limit, sol.payment_microcredits) == (300000, 2_500_000) + + +def test_quote_gas_payment_failure_modes(bridge): + configs = bridge.aleo.mappings["hyp_hook_manager.aleo"]["destination_gas_configs"] + configs[IGP_KEY_ETH] = "{ gas_overhead: 0u128, exchange_rate: 0u128, gas_price: 0u128 }" + with pytest.raises(ConfigurationError, match="unpriced"): + bridge.hyperlane.quote_gas_payment("aleo/eth") + del configs[IGP_KEY_ETH] + with pytest.raises(ConfigurationError, match="missing on chain"): + bridge.hyperlane.quote_gas_payment("aleo/eth") + with pytest.raises(UnsupportedRouteError): + bridge.hyperlane.quote_gas_payment("ethereum/eth") + + +def test_zero_gas_limit_falls_back_to_50000(bridge): + from dataclasses import replace + route = REG.route("hyperlane:aleo/eth->ethereum/eth") + zero = replace(route, metadata={**route.metadata, "aleoRemoteRouterGas": "0"}) + quote = bridge.hyperlane.quote_gas_payment(zero) + assert quote.gas_limit == 50_000 and quote.payment_microcredits == (50_000 + 159337) * 1000000000 * 402 // 10**10 + + +def test_transfer_remote_builds_call_with_live_quote(bridge): + # WBTC shares the Ethereum IGP config (same destination domain 1u32) but carries its own + # aleoRemoteRouterGas (68000, vs. ETH's 44000); per compute_gas_payment the live quote is + # (68000 + 159337) * 1_000_000_000 * 402 // 10_000_000_000 == 9138947, not ETH's 8174147. + call = bridge.hyperlane.transfer_remote("aleo/wbtc", EVM1, amount="0.0001", as_signer=True) + assert (call.program_id, call.function_name) == ("hyp_warp_token_wbtc_v2.aleo", "transfer_remote_as_signer") + assert call.inputs[5] == "10000u128" and "amount: 9138947u64" in call.inputs[6] + assert bridge.aleo.submitted == [] # nothing sent until a verb runs + result = call.delegate(wait=False) + assert isinstance(result, DispatchReceipt) + assert (result.transaction_id, result.route_id, result.message_id, result.amount_atomic) == ("at1delegated", "hyperlane:aleo/wbtc->ethereum/wbtc", None, 10_000) + assert result.receipt.status is Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == "at1delegated" + assert result.receipt.protocol_state == {"routeId": "hyperlane:aleo/wbtc->ethereum/wbtc", "sourceProgram": "hyp_warp_token_wbtc_v2.aleo", + "sourceFunction": "transfer_remote_as_signer", "amountAtomic": "10000", + "recipient": EVM1, "gasPaymentMicrocredits": "9138947"} + assert bridge.aleo.calls[-1] == ("hyp_warp_token_wbtc_v2.aleo", "transfer_remote_as_signer", call.inputs) + + +def test_transfer_remote_pins_explicit_gas_payment(bridge): + call = bridge.hyperlane.transfer_remote("aleo/eth", EVM1, amount_atomic=1, gas_payment_microcredits=123) + assert call.function_name == "transfer_remote" and "amount: 123u64" in call.inputs[6] + assert "hyp_hook_manager.aleo" not in bridge.aleo.fetched # no quote read when pinned + + +def test_is_delivered_reads_mailbox_deliveries(bridge): + assert bridge.hyperlane.is_delivered("0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d") is True + assert bridge.hyperlane.is_delivered(bytes.fromhex("c7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d")) is True + assert bridge.hyperlane.is_delivered("0x" + "00" * 32) is False + with pytest.raises(ConfigurationError, match="32-byte message id"): + bridge.hyperlane.is_delivered("0x1234") From 8180a5b7640182f31412d158793d6479c1eac893 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 18:19:07 -0400 Subject: [PATCH 10/94] fix(bridge-sdk): stop test_import_without_optional_extras leaking reimported 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. --- bridge-sdk/tests/test_package.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py index 13c0639b..1600b2fd 100644 --- a/bridge-sdk/tests/test_package.py +++ b/bridge-sdk/tests/test_package.py @@ -9,7 +9,8 @@ def test_import_without_optional_extras(monkeypatch): monkeypatch.setitem(sys.modules, mod, None) # any import of these now raises ImportError for name in list(sys.modules): if name.startswith("aleo_bridge"): - del sys.modules[name] + monkeypatch.delitem(sys.modules, name) # reverted at teardown — a fresh reimport below must not + # leak new module/class objects into tests that run after this one pkg = importlib.import_module("aleo_bridge") assert pkg.__version__ == "0.1.0" assert issubclass(pkg.RouteNotFoundError, pkg.BridgeError) From f64ae42efa7bbee7ce50d014a96c2ce81ab38828 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 18:19:11 -0400 Subject: [PATCH 11/94] feat(bridge-sdk): xReserve burn and private_mint builders, Circle attestation client, nullifier read --- bridge-sdk/python/aleo_bridge/circle.py | 51 ++++++ bridge-sdk/python/aleo_bridge/xreserve.py | 194 ++++++++++++++++++++++ bridge-sdk/tests/test_circle.py | 61 +++++++ bridge-sdk/tests/test_xreserve.py | 147 ++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/circle.py create mode 100644 bridge-sdk/python/aleo_bridge/xreserve.py create mode 100644 bridge-sdk/tests/test_circle.py create mode 100644 bridge-sdk/tests/test_xreserve.py diff --git a/bridge-sdk/python/aleo_bridge/circle.py b/bridge-sdk/python/aleo_bridge/circle.py new file mode 100644 index 00000000..ef94f599 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/circle.py @@ -0,0 +1,51 @@ +"""Circle xReserve attester — read-only HTTP (port of veil ``getAttestation``). Never signs or moves funds.""" +from __future__ import annotations + +from typing import Any + +import requests + +from . import encoding as enc +from ._keccak import keccak256 +from .errors import AttestationError, ConfigurationError +from .types import Attestation + + +class CircleClient: + """``GET {base_url}/{messageHash}``: 404 → ``None`` (pending); 200 → a verified :class:`Attestation`.""" + + def __init__(self, base_url: str, session: Any = None, timeout: float = 30.0) -> None: + if not isinstance(base_url, str) or not base_url.startswith("https://"): + raise ConfigurationError(f"Circle attestation base URL must start with https://, got {base_url!r}") + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = session if session is not None else requests.Session() + + def get_attestation(self, message_hash_hex: str) -> Attestation | None: + try: + digest = enc.hex_to_bytes(message_hash_hex, 32) + except ValueError as exc: + raise AttestationError(f"Circle attestation lookup needs a 32-byte message hash, got {message_hash_hex!r}") from exc + response = self._session.get(f"{self.base_url}/{enc.to_hex(digest)}", timeout=self.timeout) + if response.status_code == 404: + return None + if response.status_code != 200: + raise AttestationError(f"Circle attester request failed with HTTP {response.status_code}") + body = response.json() + value = body.get("attestation") if isinstance(body, dict) else None + if not isinstance(value, dict): + raise AttestationError("Circle attester returned an invalid response (no attestation object)") + try: + payload = enc.hex_to_bytes(value["payload"], enc.XRESERVE_PAYLOAD_BYTES) + signature = enc.hex_to_bytes(value["attestation"], enc.HOOK_DATA_BYTES) + echoed = enc.hex_to_bytes(value["messageHash"], 32) + except (KeyError, TypeError, ValueError) as exc: + raise AttestationError("Circle attester returned an invalid response (payload/attestation/messageHash)") from exc + if echoed != digest: + raise AttestationError("Circle attester echoed a different message hash than requested") + if keccak256(payload) != digest: + raise AttestationError("Circle attestation payload does not match the requested message hash") + return Attestation(payload=payload, message_hash=digest, attestation=signature, status="complete") + + +__all__ = ["CircleClient"] diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py new file mode 100644 index 00000000..b0e35ae8 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -0,0 +1,194 @@ +"""Aleo side of Circle xReserve (port of veil protocols/xreserve/aleoToEvm.ts, the ``complete`` half of +evmToAleo.ts, and utils/xreserveDelivery.ts): USDCx burns, the user-signed private mint, attestation +lookup and the ``nullifier`` delivery read.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from . import encoding as enc +from ._calls import AleoCall +from ._keccak import keccak256 +from .circle import CircleClient +from .errors import (AttestationError, ConfigurationError, InvalidAmountError, RouteNotFoundError, + RouteUnavailableError, UnsupportedRouteError) +from .registry import Route +from .types import Attestation, BurnReceipt, MintReceipt, Receipt, Status +from .units import format_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +ETHEREUM_DESTINATION_DOMAIN = 0 +BURN_MODES = ("private", "public", "public-as-signer") + + +class XReserveModule: + """``bridge.xreserve`` — every xReserve step that happens on Aleo.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + self.circle_session: Any = None # injectable HTTP session (tests); None → requests.Session() + + # ── routes ── + def _aleo_chain_id(self) -> str: + chains = [c for c in self._bridge.registry.chains(environment=self._bridge.environment) if c.family == "aleo"] + if len(chains) != 1: + raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self._bridge.environment}") + return chains[0].id + + def _single(self, direction: str) -> Route: + registry, aleo = self._bridge.registry, self._aleo_chain_id() + matches = [r for r in registry.routes(protocol="xreserve", include_unavailable=True, environment=self._bridge.environment) + if registry.asset(r.destination_asset_id if direction == "inbound" else r.source_asset_id).chain_id == aleo] + if not matches: + raise RouteNotFoundError(f"No {direction} xReserve route for {self._bridge.environment}") + if len(matches) > 1: + raise ConfigurationError(f"Several {direction} xReserve routes for {self._bridge.environment}: {[r.id for r in matches]}") + if not matches[0].active: + raise RouteUnavailableError(f"xReserve route is not executable: {matches[0].id}") + return matches[0] + + def inbound_route(self) -> Route: + """Ethereum → Aleo (mint side) for this environment.""" + return self._single("inbound") + + def outbound_route(self) -> Route: + """Aleo → Ethereum (burn side) for this environment.""" + return self._single("outbound") + + def _validated(self, route: Route, *, direction: str) -> Route: + registry = self._bridge.registry + if route.protocol != "xreserve": + raise UnsupportedRouteError(f"Not an xReserve route: {route.id}") + if not route.active: + raise RouteUnavailableError(f"xReserve route is not executable: {route.id}") + source = registry.chain(registry.asset(route.source_asset_id).chain_id).family + destination = registry.chain(registry.asset(route.destination_asset_id).chain_id).family + if direction == "burn" and (source, destination) != ("aleo", "evm"): + raise UnsupportedRouteError(f"USDCx burn requires an Aleo-to-Ethereum route, got {route.id}") + if direction == "mint" and (source, destination) != ("evm", "aleo"): + raise UnsupportedRouteError(f"private_mint requires an Ethereum-to-Aleo route, got {route.id}") + if route.meta_int("ethereumDestinationDomain") != ETHEREUM_DESTINATION_DOMAIN: + raise ConfigurationError(f"xReserve Ethereum destination domain must be {ETHEREUM_DESTINATION_DOMAIN}: {route.id}") + return route + + # ── burn ── + def build_burn_inputs(self, route: Route, *, mode: str, amount_atomic: int, recipient: str, + record: str | None, merkle_proof: str | None) -> tuple[str, str, list[str]]: + """``(program, function, inputs)`` for one USDCx burn (brief §3.4). Pure.""" + if mode not in BURN_MODES: + raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + self._validated(route, direction="burn") + source = self._bridge.registry.asset(route.source_asset_id) + if amount_atomic <= 0: + raise InvalidAmountError("USDCx burn amount must be greater than zero") + fee = int(route.meta_str("withdrawalFeeAtomic")) + if amount_atomic <= fee: + raise InvalidAmountError( + f"USDCx burn amount must exceed the {format_decimal_amount(fee, source.decimals)} {source.symbol} withdrawal fee") + recipient32 = enc.evm_address_to_bytes32(recipient) # InvalidRecipientError + amount_lit, domain_lit, recipient_lit = f"{amount_atomic}u128", f"{ETHEREUM_DESTINATION_DOMAIN}u32", enc.u8_array_literal(recipient32) + if mode == "private": + if not isinstance(record, str) or not record.strip(): + raise ConfigurationError(f"private_burn requires a USDCx Token record from {route.meta_str('remoteToken')}") + if not isinstance(merkle_proof, str) or not (merkle_proof.startswith("[") and merkle_proof.endswith("]")): + raise ConfigurationError("private_burn requires an encoded [MerkleProof; 2] Aleo literal") + return route.meta_str("wrapperProgram"), "private_burn", [record, amount_lit, domain_lit, recipient_lit, merkle_proof] + function = "burn_public" if mode == "public" else "burn_public_as_signer" + return route.meta_str("bridgeProgram"), function, [amount_lit, domain_lit, recipient_lit] + + def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mode: str = "private", + record: str | None = None, merkle_proof: str | None = None) -> AleoCall[BurnReceipt]: + """Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a + freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than + the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle.""" + route = self._validated(self.outbound_route(), direction="burn") + source = self._bridge.registry.asset(route.source_asset_id) + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + if mode == "private": + token_program = route.meta_str("remoteToken") + if record is None: + privacy = getattr(self._bridge, "privacy", None) + if privacy is None: + raise ConfigurationError( + "burn(mode='private') needs a record; pass record= explicitly (bridge.privacy is not available yet)") + record = privacy.select_record(token_program, atomic) + if merkle_proof is None: + freezelist = getattr(self._bridge, "freezelist", None) + if freezelist is None: + raise ConfigurationError( + "burn(mode='private') needs merkle_proof; pass merkle_proof= explicitly " + "(bridge.freezelist is not available yet)") + merkle_proof = freezelist.exclusion_proof(self._bridge.aleo_address(), token_program) + program, function, inputs = self.build_burn_inputs(route, mode=mode, amount_atomic=atomic, recipient=recipient, + record=record, merkle_proof=merkle_proof) + recipient_hex = enc.to_hex(enc.evm_address_to_bytes32(recipient)) + + def build(tx_id: str, _outputs: list[str]) -> BurnReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": route.id, "burnMode": mode, "amountAtomic": str(atomic), + "nativeDomain": ETHEREUM_DESTINATION_DOMAIN, "nativeRecipientBytes32": recipient_hex, + "sourceProgram": program, "sourceFunction": function, + "forwardingService": "aleo-burn-attestation"}) + return BurnReceipt(transaction_id=tx_id, route_id=route.id, mode=mode, amount_atomic=atomic, receipt=receipt) + + return self._bridge._call(program, function, inputs, build) + + # ── private mint ── + def hook_data(self, mode: str, recipient: str, secret_nonce: str = "0scalar") -> bytes: + return enc.xreserve_hook_data(mode, recipient, self._bridge.environment, secret_nonce) + + def build_private_mint_inputs(self, route: Route, attestation: Attestation, recipient: str, secret_nonce: str) -> list[str]: + """The five ``private_mint`` literals (brief §3.5) after re-verifying hash and the (recipient, nonce) commitment.""" + self._validated(route, direction="mint") + enc.validate_scalar(secret_nonce) + if attestation.status != "complete": + raise AttestationError("Private mint requires a completed Circle attestation; it is still pending") + payload, signature, digest = bytes(attestation.payload), bytes(attestation.attestation), bytes(attestation.message_hash) + if len(payload) != enc.XRESERVE_PAYLOAD_BYTES or len(signature) != enc.HOOK_DATA_BYTES or len(digest) != 32: + raise AttestationError("Circle attestation has invalid widths (expected 305-byte payload, 65-byte signature, 32-byte hash)") + if keccak256(payload) != digest: + raise AttestationError("Circle attestation payload has an invalid message hash") + expected_hook = self.hook_data("private", recipient, secret_nonce) + if payload[-enc.HOOK_DATA_BYTES:] != expected_hook: + raise AttestationError("Private mint secret nonce and recipient do not match the attested hook data") + return [enc.u8_array_literal(payload), enc.u8_array_literal(signature), enc.u8_array_literal(digest), secret_nonce, recipient] + + def private_mint(self, attestation: Attestation, recipient: str, *, secret_nonce: str = "0scalar", + route: Route | None = None) -> AleoCall[MintReceipt]: + """Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``).""" + route = self._validated(route if route is not None else self.inbound_route(), direction="mint") + inputs = self.build_private_mint_inputs(route, attestation, recipient, secret_nonce) + program = route.meta_str("wrapperProgram") + message_hash = enc.to_hex(attestation.message_hash) + nonce = enc.to_hex(enc.xreserve_nonce_from_payload(attestation.payload)) + + def build(tx_id: str, _outputs: list[str]) -> MintReceipt: + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.DESTINATION_CONFIRMING, destination_tx_id=tx_id, + protocol_state={"routeId": route.id, "mintMode": "private", "intendedRecipient": recipient, + "messageHash": message_hash, "nonce": nonce, + "bridgeProgram": route.meta_str("bridgeProgram"), "wrapperProgram": program, + "destinationProgram": program, "destinationFunction": "private_mint"}) + return MintReceipt(transaction_id=tx_id, route_id=route.id, receipt=receipt) + + return self._bridge._call(program, "private_mint", inputs, build) + + # ── reads ── + def get_attestation(self, message_hash: "str | bytes", *, route: Route | None = None) -> Attestation | None: + """One Circle request for *message_hash*; ``None`` while pending (404).""" + route = route if route is not None else self.inbound_route() + client = CircleClient(route.meta_str("attestationBaseUrl"), session=self.circle_session) + return client.get_attestation(enc.to_hex(enc.hex_to_bytes(message_hash))) + + def is_delivered(self, nonce: "str | bytes", *, route: Route | None = None) -> bool: + """``bridgeProgram/nullifier[nonce as [u8; 32]] == true`` — the mint already landed on Aleo.""" + route = route if route is not None else self.inbound_route() + try: + raw = enc.hex_to_bytes(nonce, 32) + except ValueError as exc: + raise ConfigurationError("xReserve delivery requires a 32-byte deposit nonce") from exc + value = self._bridge.mapping_value(route.meta_str("bridgeProgram"), "nullifier", enc.u8_array_literal(raw)) + return value is not None and value.strip() == "true" + + +__all__ = ["BURN_MODES", "ETHEREUM_DESTINATION_DOMAIN", "XReserveModule"] diff --git a/bridge-sdk/tests/test_circle.py b/bridge-sdk/tests/test_circle.py new file mode 100644 index 00000000..100eb098 --- /dev/null +++ b/bridge-sdk/tests/test_circle.py @@ -0,0 +1,61 @@ +import pytest + +from aleo_bridge._keccak import keccak256 +from aleo_bridge.circle import CircleClient +from aleo_bridge.errors import AttestationError, ConfigurationError +from aleo_bridge.types import Attestation + +BASE = "https://xreserve-api.circle.com/v1/attestations" +PAYLOAD = bytes(305) +HASH = keccak256(PAYLOAD) +SIG = bytes.fromhex("11" * 65) + + +class _Response: + def __init__(self, status_code, body=None): + self.status_code, self._body = status_code, body + + def json(self): + return self._body + + +class _Session: + def __init__(self, *responses): + self._responses, self.urls = list(responses), [] + + def get(self, url, timeout=None): + self.urls.append((url, timeout)) + return self._responses.pop(0) + + +def _body(payload=PAYLOAD, signature=SIG, message_hash=HASH): + return {"attestation": {"payload": "0x" + payload.hex(), "attestation": "0x" + signature.hex(), "messageHash": "0x" + message_hash.hex()}} + + +def test_404_is_pending_none(): + session = _Session(_Response(404)) + assert CircleClient(BASE, session=session, timeout=9).get_attestation("0x" + HASH.hex()) is None + assert session.urls == [(f"{BASE}/0x{HASH.hex()}", 9)] + + +def test_complete_attestation_is_verified(): + att = CircleClient(BASE, session=_Session(_Response(200, _body()))).get_attestation(HASH.hex()) # bare hex accepted + assert att == Attestation(payload=PAYLOAD, message_hash=HASH, attestation=SIG, status="complete") + + +def test_rejects_http_errors_and_bad_bodies(): + with pytest.raises(AttestationError, match="HTTP 500"): + CircleClient(BASE, session=_Session(_Response(500))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="invalid response"): + CircleClient(BASE, session=_Session(_Response(200, {"attestation": {"payload": "zz"}}))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="invalid response"): + CircleClient(BASE, session=_Session(_Response(200, {}))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="different message hash"): + CircleClient(BASE, session=_Session(_Response(200, _body(message_hash=bytes(32))))).get_attestation("0x" + HASH.hex()) + other = bytes.fromhex("01" * 305) + with pytest.raises(AttestationError, match="does not match the requested message hash"): + CircleClient(BASE, session=_Session(_Response(200, _body(payload=other)))).get_attestation("0x" + HASH.hex()) + with pytest.raises(AttestationError, match="32-byte"): + CircleClient(BASE, session=_Session()).get_attestation("0x1234") + with pytest.raises(ConfigurationError, match="https"): + CircleClient("http://insecure.example") diff --git a/bridge-sdk/tests/test_xreserve.py b/bridge-sdk/tests/test_xreserve.py new file mode 100644 index 00000000..7581d016 --- /dev/null +++ b/bridge-sdk/tests/test_xreserve.py @@ -0,0 +1,147 @@ +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + UnsupportedRouteError) +from aleo_bridge.registry import DEFAULT_REGISTRY as REG +from aleo_bridge.types import Attestation, BurnReceipt, MintReceipt, Status +from tests.conftest import NULLIFIED_NONCE, USDCX_RECORD + +EVM1 = "0x0000000000000000000000000000000000000001" +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +PROOF = "[{ siblings: [0field], leaf_index: 1u32 }, { siblings: [0field], leaf_index: 1u32 }]" +ONE_LIT = "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" +MAIN = REG.route("xreserve:aleo/usdcx->ethereum/usdc") +TESTNET = REG.route("xreserve:aleo-testnet/usdcx->sepolia/usdc") +INBOUND = REG.route("xreserve:ethereum/usdc->aleo/usdcx") + + +def test_routes(bridge): + assert bridge.xreserve.outbound_route().id == "xreserve:aleo/usdcx->ethereum/usdc" + assert bridge.xreserve.inbound_route().id == "xreserve:ethereum/usdc->aleo/usdcx" + + +def test_private_burn_inputs_match_veil(bridge): + program, function, inputs = bridge.xreserve.build_burn_inputs( + MAIN, mode="private", amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + assert (program, function) == ("shielded_usdcx_wrapper.aleo", "private_burn") + assert inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, PROOF] + program, function, inputs = bridge.xreserve.build_burn_inputs( + TESTNET, mode="private", amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + assert program == "shielded_usdcx_wrapper.aleo" and inputs[0] == USDCX_RECORD + + +def test_public_burn_inputs(bridge): + assert bridge.xreserve.build_burn_inputs(MAIN, mode="public", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None) == \ + ("usdcx_bridge_v2.aleo", "burn_public", ["2500000u128", "0u32", ONE_LIT]) + assert bridge.xreserve.build_burn_inputs(MAIN, mode="public-as-signer", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None)[1] == "burn_public_as_signer" + assert bridge.xreserve.build_burn_inputs(TESTNET, mode="public", amount_atomic=2_500_000, recipient=EVM1, record=None, merkle_proof=None)[0] == "test_usdcx_bridge_v2.aleo" + + +def test_burn_input_validation(bridge): + kw = dict(amount_atomic=2_500_000, recipient=EVM1, record=USDCX_RECORD, merkle_proof=PROOF) + with pytest.raises(ConfigurationError, match="Unsupported USDCx burn mode"): + bridge.xreserve.build_burn_inputs(MAIN, mode="unknown", **kw) + with pytest.raises(ConfigurationError, match="private_burn requires a USDCx Token record"): + bridge.xreserve.build_burn_inputs(MAIN, mode="private", **{**kw, "record": None}) + with pytest.raises(ConfigurationError, match=r"\[MerkleProof; 2\]"): + bridge.xreserve.build_burn_inputs(MAIN, mode="private", **{**kw, "merkle_proof": "not-a-literal"}) + with pytest.raises(InvalidAmountError, match="must exceed the 2 USDCx withdrawal fee"): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "amount_atomic": 2_000_000}) + with pytest.raises(InvalidAmountError, match="greater than zero"): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "amount_atomic": 0}) + with pytest.raises(InvalidRecipientError): + bridge.xreserve.build_burn_inputs(MAIN, mode="public", **{**kw, "recipient": "0x1234"}) + with pytest.raises(UnsupportedRouteError, match="Aleo-to-Ethereum"): + bridge.xreserve.build_burn_inputs(INBOUND, mode="public", **{**kw, "recipient": RECIPIENT}) + + +def test_burn_builds_call_and_receipt(bridge): + call = bridge.xreserve.burn(EVM1, amount="2.5", mode="private", record=USDCX_RECORD, merkle_proof=PROOF) + assert (call.program_id, call.function_name) == ("shielded_usdcx_wrapper.aleo", "private_burn") + assert call.inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, PROOF] + result = call.transact() + assert isinstance(result, BurnReceipt) + assert (result.transaction_id, result.route_id, result.mode, result.amount_atomic) == ("at1built", MAIN.id, "private", 2_500_000) + assert result.receipt.status is Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == "at1built" + assert result.receipt.protocol_state == { + "routeId": MAIN.id, "burnMode": "private", "amountAtomic": "2500000", "nativeDomain": 0, + "nativeRecipientBytes32": "0x" + "00" * 31 + "01", "sourceProgram": "shielded_usdcx_wrapper.aleo", + "sourceFunction": "private_burn", "forwardingService": "aleo-burn-attestation"} + public = bridge.xreserve.burn(EVM1, amount_atomic=3_000_000, mode="public-as-signer") + assert (public.program_id, public.function_name, public.inputs) == ("usdcx_bridge_v2.aleo", "burn_public_as_signer", ["3000000u128", "0u32", ONE_LIT]) + + +def _attested(bridge, nonce="7scalar", recipient=RECIPIENT) -> Attestation: + hook = bridge.xreserve.hook_data("private", recipient, nonce) + payload = bytes.fromhex("5a2e0acd00000001") + bytes(228) + bytes.fromhex("00000041") + hook + return Attestation(payload=payload, message_hash=enc.xreserve_message_hash(payload), attestation=bytes.fromhex("11" * 65), status="complete") + + +def test_hook_data_uses_bridge_environment(bridge): + assert bridge.xreserve.hook_data("public", RECIPIENT) == bytes(65) + assert bridge.xreserve.hook_data("private", RECIPIENT, "7scalar") == enc.xreserve_hook_data("private", RECIPIENT, "mainnet", "7scalar") + + +def test_private_mint_inputs_match_veil(bridge): + att = _attested(bridge) + inputs = bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "7scalar") + assert len(inputs) == 5 + assert inputs[0] == enc.u8_array_literal(att.payload) and inputs[0].count("u8") == 305 and " " not in inputs[0] + assert inputs[1] == "[" + ",".join(["17u8"] * 65) + "]" + assert inputs[2] == enc.u8_array_literal(att.message_hash) and inputs[2].count("u8") == 32 + assert inputs[3] == "7scalar" and inputs[4] == RECIPIENT + + +def test_private_mint_rejections(bridge): + att = _attested(bridge) + with pytest.raises(AttestationError, match="do not match the attested hook"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "8scalar") + with pytest.raises(AttestationError, match="do not match the attested hook"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px", "7scalar") + with pytest.raises(AttestationError, match="completed Circle attestation"): + bridge.xreserve.build_private_mint_inputs(INBOUND, Attestation(att.payload, att.message_hash, att.attestation, "pending"), RECIPIENT, "7scalar") + with pytest.raises(AttestationError, match="invalid message hash"): + bridge.xreserve.build_private_mint_inputs(INBOUND, Attestation(att.payload, bytes(32), att.attestation, "complete"), RECIPIENT, "7scalar") + with pytest.raises(ConfigurationError, match="scalar"): + bridge.xreserve.build_private_mint_inputs(INBOUND, att, RECIPIENT, "seven") + with pytest.raises(UnsupportedRouteError, match="Ethereum-to-Aleo"): + bridge.xreserve.build_private_mint_inputs(MAIN, att, RECIPIENT, "7scalar") + + +def test_private_mint_call_and_receipt(bridge): + att = _attested(bridge) + call = bridge.xreserve.private_mint(att, RECIPIENT, secret_nonce="7scalar") + assert (call.program_id, call.function_name) == ("shielded_usdcx_wrapper.aleo", "private_mint") + result = call.delegate(wait=False) + assert isinstance(result, MintReceipt) and result.transaction_id == "at1delegated" and result.route_id == INBOUND.id + r = result.receipt + assert r.id == enc.to_hex(att.message_hash) and r.status is Status.DESTINATION_CONFIRMING and r.destination_tx_id == "at1delegated" + assert r.protocol_state["routeId"] == INBOUND.id and r.protocol_state["mintMode"] == "private" + assert r.protocol_state["intendedRecipient"] == RECIPIENT and r.protocol_state["nonce"] == "0x" + "00" * 32 + assert r.protocol_state["destinationProgram"] == "shielded_usdcx_wrapper.aleo" and r.protocol_state["destinationFunction"] == "private_mint" + assert "secretNonce" not in r.protocol_state # the secret never travels in receipts + + +def test_get_attestation_uses_route_base_url(bridge): + class _Session: + def __init__(self): self.urls = [] + def get(self, url, timeout=None): + self.urls.append(url) + class R: status_code = 404 + return R() + session = _Session() + bridge.xreserve.circle_session = session + assert bridge.xreserve.get_attestation("0x" + "22" * 32) is None + assert session.urls == ["https://xreserve-api.circle.com/v1/attestations/0x" + "22" * 32] + bridge.xreserve.get_attestation(bytes.fromhex("33" * 32), route=REG.route("xreserve:sepolia/usdc->aleo-testnet/usdcx")) + assert session.urls[-1].startswith("https://xreserve-api-testnet.circle.com/v1/attestations/0x33") + + +def test_is_delivered_reads_bridge_program_nullifier(bridge): + assert bridge.xreserve.is_delivered(NULLIFIED_NONCE) is True + assert bridge.xreserve.is_delivered("0x" + NULLIFIED_NONCE.hex()) is True + assert bridge.xreserve.is_delivered(bytes(32)) is False + assert "usdcx_bridge_v2.aleo" in bridge.aleo.fetched + with pytest.raises(ConfigurationError, match="32-byte deposit nonce"): + bridge.xreserve.is_delivered("0x01") From b83f625a42f64e99c86f975a2a076c5ff823aecc Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 23:12:51 -0400 Subject: [PATCH 12/94] fix(bridge-sdk): wrap Circle transport and JSON failures in AttestationError --- bridge-sdk/python/aleo_bridge/circle.py | 10 ++++++-- bridge-sdk/tests/test_circle.py | 31 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/circle.py b/bridge-sdk/python/aleo_bridge/circle.py index ef94f599..e346e046 100644 --- a/bridge-sdk/python/aleo_bridge/circle.py +++ b/bridge-sdk/python/aleo_bridge/circle.py @@ -26,12 +26,18 @@ def get_attestation(self, message_hash_hex: str) -> Attestation | None: digest = enc.hex_to_bytes(message_hash_hex, 32) except ValueError as exc: raise AttestationError(f"Circle attestation lookup needs a 32-byte message hash, got {message_hash_hex!r}") from exc - response = self._session.get(f"{self.base_url}/{enc.to_hex(digest)}", timeout=self.timeout) + try: + response = self._session.get(f"{self.base_url}/{enc.to_hex(digest)}", timeout=self.timeout) + except requests.exceptions.RequestException as exc: + raise AttestationError(f"Circle attester request failed: {exc}") from exc if response.status_code == 404: return None if response.status_code != 200: raise AttestationError(f"Circle attester request failed with HTTP {response.status_code}") - body = response.json() + try: + body = response.json() + except ValueError as exc: + raise AttestationError(f"Circle attester request failed: {exc}") from exc value = body.get("attestation") if isinstance(body, dict) else None if not isinstance(value, dict): raise AttestationError("Circle attester returned an invalid response (no attestation object)") diff --git a/bridge-sdk/tests/test_circle.py b/bridge-sdk/tests/test_circle.py index 100eb098..585647b2 100644 --- a/bridge-sdk/tests/test_circle.py +++ b/bridge-sdk/tests/test_circle.py @@ -1,3 +1,4 @@ +import requests import pytest from aleo_bridge._keccak import keccak256 @@ -19,6 +20,14 @@ def json(self): return self._body +class _BadJsonResponse: + """A 200 whose body isn't valid JSON (``.json()`` raises, as ``requests`` does).""" + status_code = 200 + + def json(self): + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + class _Session: def __init__(self, *responses): self._responses, self.urls = list(responses), [] @@ -28,6 +37,15 @@ def get(self, url, timeout=None): return self._responses.pop(0) +class _RaisingSession: + """A session whose ``get`` raises a transport-level ``requests`` exception.""" + def __init__(self, exc): + self._exc = exc + + def get(self, url, timeout=None): + raise self._exc + + def _body(payload=PAYLOAD, signature=SIG, message_hash=HASH): return {"attestation": {"payload": "0x" + payload.hex(), "attestation": "0x" + signature.hex(), "messageHash": "0x" + message_hash.hex()}} @@ -59,3 +77,16 @@ def test_rejects_http_errors_and_bad_bodies(): CircleClient(BASE, session=_Session()).get_attestation("0x1234") with pytest.raises(ConfigurationError, match="https"): CircleClient("http://insecure.example") + + +def test_wraps_transport_and_json_failures(): + for exc in (requests.exceptions.ConnectionError("connection refused"), requests.exceptions.Timeout("timed out")): + session = _RaisingSession(exc) + with pytest.raises(AttestationError, match="Circle attester request failed") as excinfo: + CircleClient(BASE, session=session).get_attestation("0x" + HASH.hex()) + assert excinfo.value.__cause__ is exc + + session = _Session(_BadJsonResponse()) + with pytest.raises(AttestationError, match="Circle attester request failed") as excinfo: + CircleClient(BASE, session=session).get_attestation("0x" + HASH.hex()) + assert isinstance(excinfo.value.__cause__, ValueError) From 340e23823481cbce2327a20a97f6fe0527883290 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 23:16:17 -0400 Subject: [PATCH 13/94] feat(bridge-sdk): Sealance freeze-list Merkle tree and exclusion proofs --- bridge-sdk/python/aleo_bridge/freezelist.py | 149 ++++++++++++++++++++ bridge-sdk/tests/test_freezelist.py | 103 ++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/freezelist.py create mode 100644 bridge-sdk/tests/test_freezelist.py diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py new file mode 100644 index 00000000..af3fdc76 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -0,0 +1,149 @@ +"""Sealance compliance tree — Merkle exclusion proofs for ARC-22 (USDCx) private transfers and burns. + +Port of ``sdk/src/integrations/sealance/merkle-tree.ts``. Leaves are frozen addresses as little-endian +field ints, sorted ascending and front-padded with ``0field`` to a power of two (minimum two). The leaf +level hashes ``Poseidon4([1field, l, r])``, inner levels ``Poseidon4([0field, l, r])`` — with the +three-element array packed through ``Plaintext.to_fields()``, exactly as the Leo program does. A sibling +path starts with the leaf itself, then one sibling per level, zero-padded to ``depth`` entries. +The deployed ``MerkleProof`` struct is ``[field; 16]`` + ``u32``, so proofs use ``PROOF_SIBLINGS = 16``. +""" +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from . import encoding as enc +from .errors import ConfigurationError + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" +DEFAULT_DEPTH = 15 # TS getSiblingPath default; tree capacity 2**(depth-1) leaves +PROOF_SIBLINGS = 16 # struct MerkleProof { siblings: [field; 16u32], leaf_index: u32 } +FREEZE_LIST_MAPPING = "freeze_list" # u32 => address +FREEZE_LIST_LAST_INDEX_MAPPING = "freeze_list_last_index" # bool => u32, keyed "true" +EMPTY_TREE_ROOT = 3642222252059314292809609689035560016959342421640560347114299934615987159853 +_EMPTY_PROOF = "{ siblings: [" + ", ".join(["0field"] * PROOF_SIBLINGS) + "], leaf_index: 1u32 }" +EMPTY_MERKLE_PROOF_PAIR = f"[{_EMPTY_PROOF}, {_EMPTY_PROOF}]" + + +def address_to_field_int(address: str) -> int: + """bech32m payload bytes read little-endian — the field element a Leo program sees for an address.""" + return int.from_bytes(enc.aleo_address_to_bytes32(address), "little") + + +def hash_two(prefix: str, left: str, right: str, network: str) -> str: + """``Poseidon4(Plaintext("[prefix,left,right]").to_fields())`` as a ``…field`` literal.""" + net = enc.network_module(network) + plaintext = net.Plaintext.from_string(f"[{prefix},{left},{right}]") + return str(net.Poseidon4().hash(plaintext.to_fields())) + + +def generate_leaves(addresses: list[str], depth: int = DEFAULT_DEPTH) -> list[str]: + """Frozen addresses → sorted ``…field`` leaves, zero address dropped, front-padded to a power of two (min 2).""" + live = [a for a in addresses if a != ZERO_ADDRESS] + max_leaves = 2 ** (depth - 1) + if len(live) > max_leaves: + raise ConfigurationError(f"Leaves limit exceeded. Max: {max_leaves}, provided: {len(live)}") + count = 2 if len(live) <= 1 else 2 ** math.ceil(math.log2(len(live))) + fields = sorted(address_to_field_int(a) for a in live) + return ["0field"] * (count - len(fields)) + [f"{f}field" for f in fields] + + +def build_tree(leaves: list[str], network: str) -> list[int]: + """Bottom-up tree as ints: leaves first, root last (the layout the TS SDK and compliance API use).""" + if not leaves: + raise ConfigurationError("Leaves array cannot be empty") + if len(leaves) % 2: + raise ConfigurationError("Leaves array must have even number of elements") + tree = list(leaves) + level = list(leaves) + while len(level) > 1: + prefix = "1field" if len(level) == len(leaves) else "0field" + level = [hash_two(prefix, level[i], level[i + 1], network) for i in range(0, len(level), 2)] + tree.extend(level) + return [int(node[: -len("field")]) for node in tree] + + +def leaf_indices(tree: list[int], address: str) -> tuple[int, int]: + """(left, right) leaf indices bracketing *address* for a non-inclusion proof (TS ``getLeafIndices``).""" + count = (len(tree) + 1) // 2 + target = address_to_field_int(address) + leaves = tree[:count] + right = next((i for i, leaf in enumerate(leaves) if target <= leaf), -1) + left = right - 1 + if right == -1: + right = left = count - 1 + if right == 0: + left = 0 + return left, right + + +def sibling_path(tree: list[int], index: int, depth: int = DEFAULT_DEPTH) -> list[int]: + """Leaf, then the sibling at each level, zero-padded to *depth* entries (TS ``getSiblingPath``).""" + count = (len(tree) + 1) // 2 + path = [tree[index]] + node, parent, level = index, count, 1 + while parent < len(tree): + sibling = node + 1 if node % 2 == 0 else node - 1 + path.append(tree[sibling]) + node = parent + index // 2 ** level + parent += count // 2 ** level + level += 1 + while len(path) < depth: + path.append(0) + return path + + +def format_merkle_proof(left: tuple[list[int], int], right: tuple[list[int], int]) -> str: + """``[MerkleProof; 2]`` literal with veil's spacing: ``{ siblings: [a, b], leaf_index: Nu32 }``.""" + parts = [] + for siblings, index in (left, right): + parts.append("{ siblings: [" + ", ".join(f"{s}field" for s in siblings) + f"], leaf_index: {index}u32 }}") + return "[" + ", ".join(parts) + "]" + + +class FreezeList: + """``bridge.freezelist`` — reads a compliant token's frozen addresses and proves an address is not among them.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + def leaves(self, program: str) -> list[str]: + """Frozen addresses from ``program``'s ``freeze_list`` mapping (indices 0..last inclusive); ``[]`` when none.""" + last = self._bridge.mapping_value(program, FREEZE_LIST_LAST_INDEX_MAPPING, "true") + if last is None: + return [] + try: + count = int(last.removesuffix("u32")) + except ValueError as exc: + raise ConfigurationError(f"{program}/{FREEZE_LIST_LAST_INDEX_MAPPING} returned {last!r}, expected a u32") from exc + addresses = [] + for index in range(count + 1): + value = self._bridge.mapping_value(program, FREEZE_LIST_MAPPING, f"{index}u32") + if value and value != ZERO_ADDRESS: + addresses.append(value) + return addresses + + def tree(self, program: str) -> list[int]: + return build_tree(generate_leaves(self.leaves(program)), self._bridge.network) + + def exclusion_proof(self, address: str, program: str) -> str: + """``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty.""" + leaves = self.leaves(program) + if not leaves: + return EMPTY_MERKLE_PROOF_PAIR + tree = build_tree(generate_leaves(leaves), self._bridge.network) + count = (len(tree) + 1) // 2 + target = address_to_field_int(address) + if target in tree[:count]: + raise ConfigurationError(f"{address} is on the {program} freeze list; no exclusion proof exists for it") + left, right = leaf_indices(tree, address) + return format_merkle_proof((sibling_path(tree, left, PROOF_SIBLINGS), left), + (sibling_path(tree, right, PROOF_SIBLINGS), right)) + + +__all__ = ["DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_LAST_INDEX_MAPPING", + "FREEZE_LIST_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", "address_to_field_int", + "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", "leaf_indices", "sibling_path"] diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py new file mode 100644 index 00000000..a9be1975 --- /dev/null +++ b/bridge-sdk/tests/test_freezelist.py @@ -0,0 +1,103 @@ +import pytest + +from aleo_bridge import freezelist as fl +from aleo_bridge.errors import ConfigurationError + +# Vectors from sdk/src/integrations/sealance/merkle-tree.ts docstrings +A = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +B = "aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t" +A_FIELD = 3501665755452795161867664882580888971213780722176652848275908626939553697821 +B_FIELD = 1295133970529764960316948294624974168921228814652993007266766481909235735940 +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ZERO = fl.ZERO_ADDRESS +PROGRAM = "usdcx_stablecoin.aleo" +EMPTY_ONE = "{ siblings: [" + ", ".join(["0field"] * 16) + "], leaf_index: 1u32 }" + + +def test_empty_pair_literal_matches_veil(): + assert fl.EMPTY_MERKLE_PROOF_PAIR == f"[{EMPTY_ONE}, {EMPTY_ONE}]" + assert fl.EMPTY_MERKLE_PROOF_PAIR.count("0field") == 32 and fl.EMPTY_MERKLE_PROOF_PAIR.count("leaf_index: 1u32") == 2 + assert fl.PROOF_SIBLINGS == 16 and fl.DEFAULT_DEPTH == 15 + + +def test_address_to_field_int_matches_ts_docstrings(): + assert fl.address_to_field_int(A) == A_FIELD + assert fl.address_to_field_int(B) == B_FIELD + assert fl.address_to_field_int(ZERO) == 0 + + +def test_hash_two_uses_poseidon4_over_packed_array(): + from aleo import mainnet as net + expected = str(net.Poseidon4().hash(net.Plaintext.from_string("[1field,3field,4field]").to_fields())) + assert fl.hash_two("1field", "3field", "4field", "mainnet") == expected + assert expected.endswith("field") and expected != "0field" + assert fl.hash_two("0field", "3field", "4field", "mainnet") != expected # prefix matters (leaf vs node) + + +def test_empty_tree_root_pinned(): + # Pinned 2026-09-03 from the live compliance tree ([0, 0, root]); investigate rather than re-pin on failure. + assert fl.build_tree(["0field", "0field"], "mainnet") == [0, 0, fl.EMPTY_TREE_ROOT] + assert fl.EMPTY_TREE_ROOT == 3642222252059314292809609689035560016959342421640560347114299934615987159853 + + +def test_generate_leaves_sorts_pads_and_filters_zero(): + assert fl.generate_leaves([A, B, B]) == ["0field", f"{B_FIELD}field", f"{B_FIELD}field", f"{A_FIELD}field"] + assert fl.generate_leaves([ZERO, ZERO, A]) == ["0field", f"{A_FIELD}field"] + assert fl.generate_leaves([]) == ["0field", "0field"] + assert len(fl.generate_leaves([A] * 5)) == 8 and fl.generate_leaves([A] * 5)[:3] == ["0field"] * 3 + with pytest.raises(ConfigurationError, match="Leaves limit exceeded"): + fl.generate_leaves([A] * (2 ** 14 + 1), 15) + + +def test_build_tree_shapes_and_errors(): + two = fl.build_tree(["1field", "2field"], "mainnet") + four = fl.build_tree(["1field", "2field", "3field", "4field"], "mainnet") + assert len(two) == 3 and two[:2] == [1, 2] + assert len(four) == 7 and four[:4] == [1, 2, 3, 4] + assert f"{four[4]}field" == fl.hash_two("1field", "1field", "2field", "mainnet") # leaf level uses 1field + assert f"{four[6]}field" == fl.hash_two("0field", f"{four[4]}field", f"{four[5]}field", "mainnet") # inner level uses 0field + with pytest.raises(ConfigurationError, match="cannot be empty"): + fl.build_tree([], "mainnet") + with pytest.raises(ConfigurationError, match="even number"): + fl.build_tree(["1field", "2field", "3field"], "mainnet") + + +def test_leaf_indices_and_sibling_path(): + tree = fl.build_tree(fl.generate_leaves([A]), "mainnet") # leaves [0, A_FIELD] + assert fl.leaf_indices(tree, RECIPIENT) == (1, 1) # RECIPIENT's field > A_FIELD → clamps to the last leaf + assert fl.leaf_indices(tree, A) == (0, 1) # equal to leaf 1 → (0, 1) + four = fl.build_tree(["1field", "2field", "3field", "4field"], "mainnet") + path = fl.sibling_path(four, 1, 15) + assert len(path) == 15 and path[:3] == [2, 1, four[5]] and path[3:] == [0] * 12 # leaf, sibling, uncle, zero-padding + assert len(fl.sibling_path(four, 1, 16)) == 16 + assert fl.format_merkle_proof(([2, 1], 1), ([3, 4], 2)) == \ + "[{ siblings: [2field, 1field], leaf_index: 1u32 }, { siblings: [3field, 4field], leaf_index: 2u32 }]" + + +def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): + tree = fl.build_tree(["0field", "0field"], "mainnet") + left, right = fl.leaf_indices(tree, RECIPIENT) + assert (left, right) == (1, 1) + proof = fl.format_merkle_proof((fl.sibling_path(tree, left, fl.PROOF_SIBLINGS), left), + (fl.sibling_path(tree, right, fl.PROOF_SIBLINGS), right)) + assert proof == fl.EMPTY_MERKLE_PROOF_PAIR + + +def test_freezelist_reads_mappings_and_builds_proof(bridge): + mappings = bridge.aleo.mappings[PROGRAM] + assert bridge.freezelist.leaves(PROGRAM) == [] and bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR + mappings["freeze_list_last_index"] = {"true": "1u32"} + mappings["freeze_list"] = {"0u32": A, "1u32": ZERO} + assert bridge.freezelist.leaves(PROGRAM) == [A] # zero address filtered + assert bridge.freezelist.tree(PROGRAM)[:2] == [0, A_FIELD] + one = "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" + assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == f"[{one}, {one}]" + with pytest.raises(ConfigurationError, match="freeze list"): + bridge.freezelist.exclusion_proof(A, PROGRAM) + + +def test_freezelist_last_index_parsing_is_not_rstrip(bridge): + mappings = bridge.aleo.mappings[PROGRAM] + mappings["freeze_list_last_index"] = {"true": "12u32"} + mappings["freeze_list"] = {f"{i}u32": ZERO for i in range(12)} | {"12u32": B} + assert bridge.freezelist.leaves(PROGRAM) == [B] From ecefb1f88cdc871b9fba1781bd4652fb8f385c6d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 23:23:16 -0400 Subject: [PATCH 14/94] feat(bridge-sdk): shield/unshield for ARC-20 and ARC-22 assets with record selection --- bridge-sdk/python/aleo_bridge/privacy.py | 113 +++++++++++++++++++++++ bridge-sdk/tests/test_privacy.py | 89 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/privacy.py create mode 100644 bridge-sdk/tests/test_privacy.py diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py new file mode 100644 index 00000000..5648b5a1 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -0,0 +1,113 @@ +"""shield / unshield for Aleo assets with a privacy capability (port of veil actions/shield.ts, unshield.ts, +internal/aleoPrivacy.ts) plus local record selection through ``aleo.records.find``.""" +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +from ._calls import AleoCall +from .errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from .registry import Asset +from .types import PrivacyReceipt +from .units import format_decimal_amount, resolve_amount + +if TYPE_CHECKING: # pragma: no cover + from .client import Bridge + +_AMOUNT_RE = re.compile(r"\bamount:\s*(\d+)u128") + + +def record_amount(plaintext: str) -> int | None: + """The ``amount`` member of a Token record plaintext, or None when absent.""" + match = _AMOUNT_RE.search(plaintext or "") + return int(match.group(1)) if match else None + + +class PrivacyModule: + """``bridge.privacy`` — convert between public balances and private records.""" + + def __init__(self, bridge: "Bridge") -> None: + self._bridge = bridge + + def _asset(self, asset: Any, operation: str) -> Asset: + resolved = self._bridge.registry.asset(asset) + chain = self._bridge.registry.chain(resolved.chain_id) + if chain.family != "aleo" or resolved.privacy is None: + raise UnsupportedRouteError(f'Bridge asset "{resolved.id}" does not support {operation}') + return resolved + + def _amount(self, asset: Asset, amount: Any, amount_atomic: int | None, operation: str) -> tuple[int, str]: + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=asset.decimals) + if atomic <= 0: + raise InvalidAmountError(f"{operation} amount must be greater than zero") + return atomic, f"{atomic}u128" + + def _recipient(self, asset: Asset, recipient: str | None) -> str: + value = recipient if recipient is not None else self._bridge.aleo_address() + if not asset.matches_address(value): + raise InvalidRecipientError(f"Recipient does not match the Aleo address format: {value}") + return value + + def select_record(self, program: str, amount_atomic: int, account: Any = None) -> str: + """Smallest unspent ``program``/``Token`` record covering *amount_atomic* (plaintext string).""" + rows = self._bridge.aleo.records.find(account, program=program, record="Token") + amounts: list[tuple[int, str]] = [] + for row in rows: + plaintext = row.get("record_plaintext") if isinstance(row, dict) else getattr(row, "record_plaintext", None) + value = record_amount(plaintext) if plaintext else None + if value is not None: + amounts.append((value, plaintext)) + covering = [entry for entry in amounts if entry[0] >= amount_atomic] + if not covering: + largest = max((value for value, _ in amounts), default=0) + raise InsufficientBalanceError( + f"No unspent {program} Token record covers {amount_atomic}; largest available is {largest}. " + "Shield more, join records, or lower the amount.") + return min(covering)[1] + + def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + """Public balance → private record. ARC-22 names the private recipient; ARC-20 always credits the caller.""" + resolved = self._asset(asset, "shielding") + atomic, literal = self._amount(resolved, amount, amount_atomic, "Shielding") + privacy = resolved.privacy + assert privacy is not None + if privacy.kind == "arc22": + function, inputs = "transfer_public_to_private", [self._recipient(resolved, recipient), literal] + else: + if recipient is not None and recipient != self._bridge.aleo_address(): + raise ConfigurationError("ARC-20 shield always credits the caller; omit recipient=") + function, inputs = "shield", [literal] + human = format_decimal_amount(atomic, resolved.decimals) + + def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: + return PrivacyReceipt(tx_id, resolved.id, human, atomic, "shield") + + return self._bridge._call(privacy.program, function, inputs, build) + + def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, + merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + """Private record → public balance. The record defaults to the smallest covering one; ARC-22 also needs the + freeze-list exclusion proof (computed for the signer; veil's empty pair when the list is empty).""" + resolved = self._asset(asset, "unshielding") + atomic, literal = self._amount(resolved, amount, amount_atomic, "Unshielding") + privacy = resolved.privacy + assert privacy is not None + record = record if record is not None else self.select_record(privacy.program, atomic) + if privacy.kind == "arc22": + proof = merkle_proof if merkle_proof is not None else \ + self._bridge.freezelist.exclusion_proof(self._bridge.aleo_address(), privacy.program) + function, inputs = "transfer_private_to_public", [self._recipient(resolved, recipient), literal, record, proof] + else: + if merkle_proof is not None: + raise ConfigurationError("ARC-20 unshield takes no Merkle proof; omit merkle_proof=") + function, inputs = "unshield", [record, literal] + human = format_decimal_amount(atomic, resolved.decimals) + + def build(tx_id: str, _outputs: list[str]) -> PrivacyReceipt: + return PrivacyReceipt(tx_id, resolved.id, human, atomic, "unshield") + + return self._bridge._call(privacy.program, function, inputs, build) + + +__all__ = ["PrivacyModule", "record_amount"] diff --git a/bridge-sdk/tests/test_privacy.py b/bridge-sdk/tests/test_privacy.py new file mode 100644 index 00000000..1a7a4072 --- /dev/null +++ b/bridge-sdk/tests/test_privacy.py @@ -0,0 +1,89 @@ +import pytest + +from aleo_bridge.errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError +from aleo_bridge.freezelist import EMPTY_MERKLE_PROOF_PAIR +from aleo_bridge.privacy import record_amount +from aleo_bridge.types import PrivacyReceipt +from tests.conftest import SIGNER, USDCX_RECORD, USDCX_RECORD_SMALL + +OTHER = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +SOL_RECORD = f"{{ owner: {SIGNER}.private, amount: 300000000u128.private, _nonce: 9group.public }}" +SOL_RECORD_BIG = f"{{ owner: {SIGNER}.private, amount: 900000000u128.private, _nonce: 10group.public }}" + + +def test_record_amount_parser(): + assert record_amount(USDCX_RECORD) == 5_000_000 and record_amount(USDCX_RECORD_SMALL) == 100 + assert record_amount("{ owner: aleo1x.private, _nonce: 1group.public }") is None + assert record_amount("") is None + + +def test_shield_arc20_matches_veil(bridge): + call = bridge.privacy.shield("aleo/eth", amount="0.000000000000000001") + assert (call.program_id, call.function_name, call.inputs) == ("arc20_eth.aleo", "shield", ["1u128"]) + result = call.transact() + assert result == PrivacyReceipt("at1built", "aleo/eth", "0.000000000000000001", 1, "shield") + + +def test_shield_arc22_names_recipient(bridge): + call = bridge.privacy.shield(("aleo", "usdcx"), amount="2.5") + assert (call.program_id, call.function_name, call.inputs) == ("usdcx_stablecoin.aleo", "transfer_public_to_private", [SIGNER, "2500000u128"]) + assert bridge.privacy.shield("aleo/usdcx", amount_atomic=2_500_000, recipient=OTHER).inputs == [OTHER, "2500000u128"] + with pytest.raises(ConfigurationError, match="ARC-20 shield always credits the caller"): + bridge.privacy.shield("aleo/eth", amount="1", recipient=OTHER) + with pytest.raises(InvalidRecipientError): + bridge.privacy.shield("aleo/usdcx", amount="1", recipient="0x1234") + + +def test_unshield_arc20_selects_smallest_covering_record(bridge): + bridge.aleo.record_rows = [{"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD_BIG}, + {"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD}, + {"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD}] + call = bridge.privacy.unshield("aleo/sol", amount="0.25") + assert (call.program_id, call.function_name, call.inputs) == ("arc20_sol.aleo", "unshield", [SOL_RECORD, "250000000u128"]) + assert bridge.aleo.record_queries[-1] == {"program": "arc20_sol.aleo", "record": "Token", "unspent": True} + result = call.delegate(wait=False) + assert result == PrivacyReceipt("at1delegated", "aleo/sol", "0.25", 250_000_000, "unshield") + with pytest.raises(ConfigurationError, match="ARC-20 unshield takes no Merkle proof"): + bridge.privacy.unshield("aleo/sol", amount="0.25", merkle_proof="[x]") + + +def test_unshield_arc22_defaults_to_signer_record_and_empty_proof(bridge): + call = bridge.privacy.unshield("aleo/usdcx", amount="2.5") + assert (call.program_id, call.function_name) == ("usdcx_stablecoin.aleo", "transfer_private_to_public") + assert call.inputs[:3] == [SIGNER, "2500000u128", USDCX_RECORD] + assert call.inputs[3] == EMPTY_MERKLE_PROOF_PAIR and call.inputs[3].count("0field") == 32 + + +def test_unshield_arc22_accepts_explicit_inputs(bridge): + call = bridge.privacy.unshield("aleo/usdcx", amount="2.5", recipient=OTHER, record=USDCX_RECORD_SMALL, merkle_proof="[custom-proof]") + assert call.inputs == [OTHER, "2500000u128", USDCX_RECORD_SMALL, "[custom-proof]"] + assert bridge.aleo.record_queries == [] # explicit record: no scanner query + + +def test_unsupported_assets_and_zero_amounts(bridge): + with pytest.raises(UnsupportedRouteError, match="does not support shielding"): + bridge.privacy.shield("aleo/aleo", amount="1") + with pytest.raises(UnsupportedRouteError, match="does not support unshielding"): + bridge.privacy.unshield("aleo/aleo", amount="1") + with pytest.raises(UnsupportedRouteError, match="does not support shielding"): + bridge.privacy.shield("ethereum/usdc", amount="1") + with pytest.raises(InvalidAmountError, match="Unshielding amount must be greater than zero"): + bridge.privacy.unshield("aleo/sol", amount="0") + with pytest.raises(InvalidAmountError, match="Shielding amount must be greater than zero"): + bridge.privacy.shield("aleo/sol", amount_atomic=0) + + +def test_select_record_reports_largest_available(bridge): + bridge.aleo.record_rows = [{"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD_SMALL}] + with pytest.raises(InsufficientBalanceError, match="largest available is 100"): + bridge.privacy.select_record("usdcx_stablecoin.aleo", 2_500_000) + bridge.aleo.record_rows = [] + with pytest.raises(InsufficientBalanceError, match="largest available is 0"): + bridge.privacy.select_record("usdcx_stablecoin.aleo", 1) + + +def test_private_burn_defaults_resolve_through_privacy_and_freezelist(bridge): + ONE_LIT = "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" + call = bridge.xreserve.burn("0x0000000000000000000000000000000000000001", amount="2.5") + assert call.inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, EMPTY_MERKLE_PROOF_PAIR] + assert bridge.aleo.record_queries[-1]["program"] == "usdcx_stablecoin.aleo" From eff3201b5cf6bfb4e89eb3d43a74e146ebe61a0c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 16 Sep 2026 23:27:44 -0400 Subject: [PATCH 15/94] fix(bridge-sdk): reject mismatched recipient on ARC-20 unshield --- bridge-sdk/python/aleo_bridge/privacy.py | 2 ++ bridge-sdk/tests/test_privacy.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/bridge-sdk/python/aleo_bridge/privacy.py b/bridge-sdk/python/aleo_bridge/privacy.py index 5648b5a1..d909714a 100644 --- a/bridge-sdk/python/aleo_bridge/privacy.py +++ b/bridge-sdk/python/aleo_bridge/privacy.py @@ -99,6 +99,8 @@ def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None self._bridge.freezelist.exclusion_proof(self._bridge.aleo_address(), privacy.program) function, inputs = "transfer_private_to_public", [self._recipient(resolved, recipient), literal, record, proof] else: + if recipient is not None and recipient != self._bridge.aleo_address(): + raise ConfigurationError("ARC-20 unshield always credits the caller; omit recipient=") if merkle_proof is not None: raise ConfigurationError("ARC-20 unshield takes no Merkle proof; omit merkle_proof=") function, inputs = "unshield", [record, literal] diff --git a/bridge-sdk/tests/test_privacy.py b/bridge-sdk/tests/test_privacy.py index 1a7a4072..d6e4965a 100644 --- a/bridge-sdk/tests/test_privacy.py +++ b/bridge-sdk/tests/test_privacy.py @@ -47,6 +47,14 @@ def test_unshield_arc20_selects_smallest_covering_record(bridge): bridge.privacy.unshield("aleo/sol", amount="0.25", merkle_proof="[x]") +def test_unshield_arc20_recipient_must_match_caller(bridge): + bridge.aleo.record_rows = [{"program": "arc20_sol.aleo", "record_plaintext": SOL_RECORD}] + call = bridge.privacy.unshield("aleo/sol", amount="0.25", recipient=SIGNER) + assert (call.program_id, call.function_name, call.inputs) == ("arc20_sol.aleo", "unshield", [SOL_RECORD, "250000000u128"]) + with pytest.raises(ConfigurationError, match="ARC-20 unshield always credits the caller"): + bridge.privacy.unshield("aleo/sol", amount="0.25", recipient=OTHER) + + def test_unshield_arc22_defaults_to_signer_record_and_empty_proof(bridge): call = bridge.privacy.unshield("aleo/usdcx", amount="2.5") assert (call.program_id, call.function_name) == ("usdcx_stablecoin.aleo", "transfer_private_to_public") From 8b3d726eb673e528a4a37ab060339d718047f6fd Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 10:39:27 -0400 Subject: [PATCH 16/94] feat(bridge-sdk): Bridge client with from_env/from_profile/status, Aleo-only profile, CLI entry point --- bridge-sdk/python/aleo_bridge/__init__.py | 10 + bridge-sdk/python/aleo_bridge/__main__.py | 31 +++ bridge-sdk/python/aleo_bridge/client.py | 292 ++++++++++++++++++++++ bridge-sdk/python/aleo_bridge/profile.py | 103 ++++++++ bridge-sdk/tests/conftest.py | 46 +--- bridge-sdk/tests/test_client.py | 177 +++++++++++++ bridge-sdk/tests/test_profile.py | 48 ++++ 7 files changed, 664 insertions(+), 43 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/__main__.py create mode 100644 bridge-sdk/python/aleo_bridge/client.py create mode 100644 bridge-sdk/python/aleo_bridge/profile.py create mode 100644 bridge-sdk/tests/test_client.py create mode 100644 bridge-sdk/tests/test_profile.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 09c4a103..6c4946a8 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -21,6 +21,14 @@ MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, to_progress, ) +from ._calls import AleoCall # noqa: E402 +from .circle import CircleClient # noqa: E402 +from .client import Bridge # noqa: E402 +from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList # noqa: E402 +from .hyperlane import HyperlaneModule # noqa: E402 +from .privacy import PrivacyModule # noqa: E402 +from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 +from .xreserve import XReserveModule # noqa: E402 __all__ = [ "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", @@ -33,4 +41,6 @@ "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", + "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", ] diff --git a/bridge-sdk/python/aleo_bridge/__main__.py b/bridge-sdk/python/aleo_bridge/__main__.py new file mode 100644 index 00000000..e294d300 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/__main__.py @@ -0,0 +1,31 @@ +"""``python -m aleo_bridge [status|routes|assets]`` — status needs BRIDGE_PRIVATE_KEY (read-only).""" +from __future__ import annotations + +import dataclasses +import json +import sys + +from .registry import DEFAULT_REGISTRY + +USAGE = "usage: python -m aleo_bridge [status|routes|assets]" + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + command = args[0] if args else "status" + if command == "routes": + print(json.dumps([r.id for r in DEFAULT_REGISTRY.routes(include_unavailable=True)], indent=1)) + return 0 + if command == "assets": + print(json.dumps([a.id for a in DEFAULT_REGISTRY.assets()], indent=1)) + return 0 + if command == "status": + from .client import Bridge + print(json.dumps(dataclasses.asdict(Bridge.from_env().status()), indent=1, default=str)) + return 0 + print(USAGE, file=sys.stderr) + return 2 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py new file mode 100644 index 00000000..70631958 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -0,0 +1,292 @@ +"""Bridge — the web3.py-style client for moving assets to and from Aleo. + + bridge = Bridge(aleo) # Aleo legs: hyperlane / xreserve / privacy / freezelist + bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY, ALEO_ENDPOINT, ALEO_NETWORK, … + bridge = Bridge.from_profile() # $ALEO_BRIDGE_HOME or ~/.aleo-bridge + +Reads return values; Aleo writes return an AleoCall — nothing touches the network until a verb runs. +Ethereum/Solana connections (plans 2/3), lifecycle verbs and checkpoints (plan 4) plug into the +constructor parameters reserved here. +""" +from __future__ import annotations + +import os +import re +from typing import TYPE_CHECKING, Any, Callable + +from ._calls import AleoCall +from .errors import ConfigurationError, MissingExtraError +from .freezelist import FreezeList +from .hyperlane import HyperlaneModule +from .privacy import PrivacyModule +from .profile import DEFAULT_ENDPOINT, Profile +from .registry import DEFAULT_REGISTRY, Asset, Chain, Registry, validate_registry +from .types import BridgeStatus, ChainStatus, PrivacyReceipt +from .units import format_decimal_amount, parse_decimal_amount +from .xreserve import XReserveModule + +if TYPE_CHECKING: # pragma: no cover + from .types import Progress + +NETWORKS = ("mainnet", "testnet") +BALANCE_MAPPING = "balances" +_UINT_LITERAL_RE = re.compile(r"^(\d+)u\d+$") + + +def parse_uint_literal(value: str) -> int: + """``"2392443u64"`` → 2392443 (any ``uN`` suffix).""" + match = _UINT_LITERAL_RE.match(value.strip()) + if not match: + raise ConfigurationError(f"Expected an unsigned Aleo integer literal, got {value!r}") + return int(match.group(1)) + + +def balance_program(asset: Asset) -> str | None: + """Program whose ``balances`` mapping holds *asset*'s public balance (None for ALEO credits / no locator).""" + if asset.locator is None or asset.locator.kind != "aleo-program" or asset.locator.value == "credits.aleo": + return None + return asset.privacy.program if asset.privacy is not None else asset.locator.value + + +def build_aleo(endpoint: str, network: str, private_key: str, *, api_key: str | None = None, + consumer_id: str | None = None) -> Any: + """An ``aleo.Aleo`` facade bound to *endpoint*/*network* with *private_key* as the default account (local only).""" + from aleo import Aleo, HTTPProvider + kwargs: dict[str, Any] = {"network": network} + if api_key: + kwargs["api_key"] = api_key + if consumer_id: + kwargs["consumer_id"] = consumer_id + aleo = Aleo(HTTPProvider(endpoint, **kwargs)) + aleo.default_account = aleo.account.from_private_key(private_key) + return aleo + + +def ethereum_from_env() -> Any: + """``Ethereum(ETHEREUM_RPC_URL, private_key=EVM_PRIVATE_KEY)`` or None; both variables or neither.""" + rpc, key = os.environ.get("ETHEREUM_RPC_URL"), os.environ.get("EVM_PRIVATE_KEY") + if not rpc and not key: + return None + if not (rpc and key): + raise ConfigurationError("Set EVM_PRIVATE_KEY and ETHEREUM_RPC_URL together (both or neither)") + try: + from .eth import Ethereum # plan 2 + except ImportError as exc: + raise MissingExtraError("evm", "An Ethereum connection from EVM_PRIVATE_KEY/ETHEREUM_RPC_URL") from exc + return Ethereum(rpc, private_key=key) + + +def solana_from_env() -> Any: + """``Solana(SOLANA_RPC_URL, private_key=SOLANA_PRIVATE_KEY)`` or None (RPC optional).""" + key = os.environ.get("SOLANA_PRIVATE_KEY") + if not key: + return None + try: + from .sol import Solana # plan 3 + except ImportError as exc: + raise MissingExtraError("solana", "A Solana connection from SOLANA_PRIVATE_KEY") from exc + return Solana(os.environ.get("SOLANA_RPC_URL"), private_key=key) + + +def checkpoints_from_env() -> Any: + """``FileCheckpointStore(BRIDGE_CHECKPOINT_DIR)`` or None.""" + directory = os.environ.get("BRIDGE_CHECKPOINT_DIR") + if not directory: + return None + try: + from .checkpoint import FileCheckpointStore # plan 4 + except ImportError as exc: + raise ConfigurationError("BRIDGE_CHECKPOINT_DIR needs the checkpoint store that arrives with plan 4; unset it for now") from exc + return FileCheckpointStore(directory) + + +def _checkpoints_for_profile(profile: Profile) -> Any: + try: + from .checkpoint import FileCheckpointStore # plan 4 + except ImportError: + return None + return FileCheckpointStore(profile.checkpoint_dir) + + +class Bridge: + """Typed bridge client over the Aleo facade (and, from plans 2/3, Ethereum/Solana connections).""" + + def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, environment: str | None = None, + registry: Registry | None = None, checkpoints: Any = None) -> None: + network = getattr(aleo, "network_name", None) + if network not in NETWORKS: + raise ConfigurationError(f"The aleo facade must report network_name mainnet or testnet, got {network!r}") + environment = environment if environment is not None else network + if environment not in NETWORKS: + raise ConfigurationError(f"environment must be mainnet or testnet, got {environment!r}") + if environment != network: + raise ConfigurationError(f"Bridge environment {environment!r} does not match the facade network {network!r}") + self.aleo = aleo + self.environment: str = environment + self.network: str = network + self.registry: Registry = validate_registry(registry if registry is not None else DEFAULT_REGISTRY) + if not self.registry.chains(environment=environment): + raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") + self.checkpoints = checkpoints + self.ethereum = ethereum + self.solana = solana + self.profile: Profile | None = None + self._eth_module: Any = None + self._sol_module: Any = None + self._programs: dict[str, Any] = {} + self.hyperlane = HyperlaneModule(self) + self.xreserve = XReserveModule(self) + self.freezelist = FreezeList(self) + self.privacy = PrivacyModule(self) + + def __repr__(self) -> str: + return f"Bridge(environment={self.environment!r}, registry={self.registry.version!r})" + + # ── side-chain namespaces (plans 2/3 supply the modules) ── + @property + def eth(self) -> Any: + if self.ethereum is None: + raise ConfigurationError("Ethereum is not configured: Bridge(aleo, ethereum=Ethereum(...)) or set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL") + if self._eth_module is None: + try: + from .eth import Ethereum, EthModule # plan 2 + except ImportError as exc: + raise MissingExtraError("evm", "Ethereum-origin bridging") from exc + connection = self.ethereum if isinstance(self.ethereum, Ethereum) else Ethereum(w3=self.ethereum) + self._eth_module = EthModule(self, connection) + return self._eth_module + + @property + def sol(self) -> Any: + if self.solana is None: + raise ConfigurationError("Solana is not configured: Bridge(aleo, solana=Solana(...)) or set SOLANA_PRIVATE_KEY") + if self._sol_module is None: + try: + from .sol import Solana, SolModule # plan 3 + except ImportError as exc: + raise MissingExtraError("solana", "Solana-origin bridging") from exc + connection = self.solana if isinstance(self.solana, Solana) else Solana(client=self.solana) + self._sol_module = SolModule(self, connection) + return self._sol_module + + # ── identity / registry helpers ── + def aleo_chain(self) -> Chain: + chains = [c for c in self.registry.chains(environment=self.environment) if c.family == "aleo"] + if len(chains) != 1: + raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self.environment}") + return chains[0] + + def aleo_address(self) -> str: + account = getattr(self.aleo, "default_account", None) + if not account: + raise ConfigurationError("aleo.default_account is not set; assign aleo.account.from_private_key(...) first") + return str(account.address) + + def to_atomic(self, amount: Any, asset: Any) -> int: + return parse_decimal_amount(amount, self.registry.asset(asset).decimals) + + def from_atomic(self, atomic: int, asset: Any) -> str: + return format_decimal_amount(atomic, self.registry.asset(asset).decimals) + + # ── facade seams used by every module ── + def program(self, program_id: str) -> Any: + """Facade ``Program`` for *program_id*, fetched once per client.""" + if program_id not in self._programs: + self._programs[program_id] = self.aleo.programs.get(program_id) + return self._programs[program_id] + + def mapping_value(self, program_id: str, mapping: str, key: str) -> str | None: + """Mapping value as a string, or None when the key is absent/null.""" + value = self.program(program_id).mapping(mapping).get(key) + if value is None: + return None + text = str(value).strip().strip('"') + return None if text in ("", "null", "None") else text + + def _import_sources(self, program_id: str) -> dict[str, str]: + """``{program_id: source}`` for every transitive import (dependencies first) and the root last.""" + ordered: dict[str, str] = {} + + def visit(pid: str) -> None: + if pid in ordered: + return + program = self.program(pid) + for dep in program.imports: + visit(str(dep)) + ordered[pid] = str(program.source) + + visit(program_id) + return ordered + + def _call(self, program_id: str, function: str, inputs: list[str], build_result: Callable[[str, list[str]], Any]) -> AleoCall: + bound = self.program(program_id).functions[function](*inputs) + return AleoCall(self.aleo, bound, build_result, imports=self._import_sources(program_id)) + + # ── privacy shortcuts ── + def shield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, + recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + return self.privacy.shield(asset, amount=amount, amount_atomic=amount_atomic, recipient=recipient) + + def unshield(self, asset: Any, *, amount: Any = None, amount_atomic: int | None = None, record: str | None = None, + merkle_proof: str | None = None, recipient: str | None = None) -> AleoCall[PrivacyReceipt]: + return self.privacy.unshield(asset, amount=amount, amount_atomic=amount_atomic, record=record, + merkle_proof=merkle_proof, recipient=recipient) + + # ── status ── + def _public_balance(self, asset: Asset, address: str) -> int: + if asset.locator is not None and asset.locator.value == "credits.aleo": + value = self.mapping_value("credits.aleo", "account", address) + else: + program = balance_program(asset) + if program is None: + return 0 + value = self.mapping_value(program, BALANCE_MAPPING, address) + return parse_uint_literal(value) if value is not None else 0 + + def status(self) -> BridgeStatus: + """Read-only re-orientation: addresses and public balances of every registry asset per configured chain. + Plans 2/3 append EVM/Solana ChainStatus entries; plan 4 fills ``pending`` from the checkpoint store.""" + chain = self.aleo_chain() + account = getattr(self.aleo, "default_account", None) + address = str(account.address) if account else None + balances = {asset.id: (self._public_balance(asset, address) if address else 0) + for asset in self.registry.assets(chain=chain.id)} + pending: list["Progress"] = [] + return BridgeStatus(environment=self.environment, registry_version=self.registry.version, + chains=[ChainStatus(chain.id, address, address is not None, balances)], pending=pending) + + # ── constructors ── + @classmethod + def from_env(cls, **overrides: Any) -> "Bridge": + """Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints.""" + unexpected = set(overrides) - {"ethereum", "solana", "registry", "checkpoints"} + if unexpected: + raise TypeError(f"Bridge.from_env() got unexpected overrides: {sorted(unexpected)}") + private_key = os.environ.get("BRIDGE_PRIVATE_KEY") + if not private_key: + raise ConfigurationError("BRIDGE_PRIVATE_KEY is required (an APrivateKey1... string)") + aleo = build_aleo(os.environ.get("ALEO_ENDPOINT", DEFAULT_ENDPOINT), os.environ.get("ALEO_NETWORK", "mainnet"), + private_key, api_key=os.environ.get("ALEO_API_KEY"), consumer_id=os.environ.get("ALEO_CONSUMER_ID")) + ethereum = overrides["ethereum"] if "ethereum" in overrides else ethereum_from_env() + solana = overrides["solana"] if "solana" in overrides else solana_from_env() + checkpoints = overrides["checkpoints"] if "checkpoints" in overrides else checkpoints_from_env() + return cls(aleo, ethereum=ethereum, solana=solana, registry=overrides.get("registry"), checkpoints=checkpoints) + + @classmethod + def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: str | None = None, + ethereum: Any = None, solana: Any = None) -> "Bridge": + """The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when + creating. Side-chain connections come from the arguments or the same env variables as ``from_env``.""" + kwargs = {k: v for k, v in (("network", network), ("endpoint", endpoint)) if v is not None} + profile = Profile.load_or_create(home, **kwargs) + aleo = build_aleo(profile.endpoint, profile.network, profile.private_key, + api_key=os.environ.get("ALEO_API_KEY"), consumer_id=os.environ.get("ALEO_CONSUMER_ID")) + bridge = cls(aleo, ethereum=ethereum if ethereum is not None else ethereum_from_env(), + solana=solana if solana is not None else solana_from_env(), + checkpoints=_checkpoints_for_profile(profile)) + bridge.profile = profile + return bridge + + +__all__ = ["BALANCE_MAPPING", "Bridge", "balance_program", "build_aleo", "checkpoints_from_env", "ethereum_from_env", + "parse_uint_literal", "solana_from_env"] diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py new file mode 100644 index 00000000..0ac2ab6b --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -0,0 +1,103 @@ +"""On-disk Aleo identity for the bridge — one profile per home directory (``$ALEO_BRIDGE_HOME`` or ``~/.aleo-bridge``). + +Holds ONLY the Aleo key (mode 600). EVM and Solana keys are never written by this package; they come from +``EVM_PRIVATE_KEY`` / ``SOLANA_PRIVATE_KEY`` or caller-built connections. Shares no code with shield-swap. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .errors import ConfigurationError + +DEFAULT_ENDPOINT = "https://edge.provable.com/api" +NETWORKS = ("mainnet", "testnet") +_PROFILE = "profile.json" +_CHECKPOINTS = "checkpoints" + + +def _write_private(path: Path, payload: dict[str, Any]) -> None: + """Owner-only file written atomically (no umask window, no torn reads).""" + tmp = path.with_suffix(path.suffix + ".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(payload, indent=1)) + os.replace(tmp, path) + + +def _initial_key(network: str) -> tuple[str, str]: + """(private_key, address): imported from BRIDGE_PRIVATE_KEY / BRIDGE_PRIVATE_KEY_FILE, else freshly random.""" + import aleo + net = getattr(aleo, network) + key = (os.environ.get("BRIDGE_PRIVATE_KEY") or "").strip() + key_file = os.environ.get("BRIDGE_PRIVATE_KEY_FILE") + if not key and key_file: + key = Path(key_file).expanduser().read_text().strip() + private_key = net.PrivateKey.from_string(key) if key else net.PrivateKey.random() + return str(private_key), str(private_key.address) + + +class Profile: + """A persistent Aleo identity: created on first use, reused every session after. + + ``Profile.load_or_create()`` does both, so callers never branch on existence. *network* and + *endpoint* apply only when creating; an existing profile keeps the values it was created with. + """ + + def __init__(self, home: Path, data: dict[str, Any]) -> None: + self.home = home + self._data = data + + def __repr__(self) -> str: + return f"Profile({self.address!r}, network={self.network!r}, home={str(self.home)!r})" + + @staticmethod + def default_home() -> Path: + env = os.environ.get("ALEO_BRIDGE_HOME") + return Path(env).expanduser() if env else Path.home() / ".aleo-bridge" + + @classmethod + def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mainnet", + endpoint: str = DEFAULT_ENDPOINT) -> "Profile": + if network not in NETWORKS: + raise ConfigurationError(f"Profile network must be one of {NETWORKS}, got {network!r}") + home_path = Path(home).expanduser() if home is not None else cls.default_home() + path = home_path / _PROFILE + if path.exists(): + path.chmod(0o600) # heal a loose mode on load + profile = cls(home_path, json.loads(path.read_text())) + else: + home_path.mkdir(parents=True, exist_ok=True) + private_key, address = _initial_key(network) + data = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} + _write_private(path, data) + profile = cls(home_path, data) + profile.checkpoint_dir.mkdir(parents=True, exist_ok=True) + return profile + + @property + def address(self) -> str: + return self._data["address"] + + @property + def private_key(self) -> str: + """Controls the account — never log it or send it to a service.""" + return self._data["private_key"] + + @property + def network(self) -> str: + return self._data["network"] + + @property + def endpoint(self) -> str: + return self._data.get("endpoint", DEFAULT_ENDPOINT) + + @property + def checkpoint_dir(self) -> Path: + """Directory plan 4 binds as ``FileCheckpointStore``; created with the profile.""" + return self.home / _CHECKPOINTS + + +__all__ = ["DEFAULT_ENDPOINT", "Profile"] diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 71748b40..9e07a501 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -1,7 +1,6 @@ """Hermetic stand-ins for the aleo facade. Records every call so tests assert on exact inputs.""" from __future__ import annotations -import importlib import json from typing import Any @@ -226,46 +225,7 @@ def fake_aleo(monkeypatch) -> FakeAleo: return FakeAleo(mappings=default_mappings()) -class _BridgeStub: - """The five Bridge seams protocol modules use, until Task 11 wires the real Bridge into this fixture.""" - - def __init__(self, aleo: FakeAleo) -> None: - from aleo_bridge._calls import AleoCall - from aleo_bridge.registry import DEFAULT_REGISTRY - - self.aleo = aleo - self.registry = DEFAULT_REGISTRY - self.environment = self.network = aleo.network_name - self._AleoCall = AleoCall - self._programs: dict = {} - for attr, module, cls in (("hyperlane", "hyperlane", "HyperlaneModule"), ("xreserve", "xreserve", "XReserveModule"), - ("freezelist", "freezelist", "FreezeList"), ("privacy", "privacy", "PrivacyModule")): - try: - setattr(self, attr, getattr(importlib.import_module(f"aleo_bridge.{module}"), cls)(self)) - except ImportError: - setattr(self, attr, None) - - def aleo_address(self) -> str: - return str(self.aleo.default_account.address) - - def program(self, program_id: str): - if program_id not in self._programs: - self._programs[program_id] = self.aleo.programs.get(program_id) - return self._programs[program_id] - - def mapping_value(self, program_id: str, mapping: str, key: str) -> str | None: - value = self.program(program_id).mapping(mapping).get(key) - if value is None: - return None - text = str(value).strip().strip('"') - return None if text in ("", "null", "None") else text - - def _call(self, program_id: str, function: str, inputs: list[str], build_result): - program = self.program(program_id) - bound = program.functions[function](*inputs) - return self._AleoCall(self.aleo, bound, build_result, imports={program_id: program.source}) - - @pytest.fixture -def bridge(fake_aleo) -> Any: - return _BridgeStub(fake_aleo) +def bridge(fake_aleo): + from aleo_bridge import Bridge + return Bridge(fake_aleo) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py new file mode 100644 index 00000000..c1849495 --- /dev/null +++ b/bridge-sdk/tests/test_client.py @@ -0,0 +1,177 @@ +import json + +import pytest + +from aleo_bridge import Bridge, __main__ as cli +from aleo_bridge._calls import AleoCall +from aleo_bridge.errors import ConfigurationError, MissingExtraError +from aleo_bridge.freezelist import FreezeList +from aleo_bridge.hyperlane import HyperlaneModule +from aleo_bridge.privacy import PrivacyModule +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry +from aleo_bridge.types import BridgeStatus +from aleo_bridge.xreserve import XReserveModule +from tests.conftest import SIGNER, FakeAleo, default_mappings + + +def test_construction_defaults_and_namespaces(fake_aleo): + bridge = Bridge(fake_aleo) + assert (bridge.environment, bridge.network, bridge.registry) == ("mainnet", "mainnet", DEFAULT_REGISTRY) + assert bridge.checkpoints is None and bridge.ethereum is None and bridge.solana is None and bridge.profile is None + assert isinstance(bridge.hyperlane, HyperlaneModule) and isinstance(bridge.xreserve, XReserveModule) + assert isinstance(bridge.freezelist, FreezeList) and isinstance(bridge.privacy, PrivacyModule) + assert bridge.aleo_chain().id == "aleo" and bridge.aleo_address() == SIGNER + assert Bridge(FakeAleo(network_name="testnet")).aleo_chain().id == "aleo-testnet" + + +def test_construction_errors(fake_aleo): + with pytest.raises(ConfigurationError, match="does not match"): + Bridge(fake_aleo, environment="testnet") + with pytest.raises(ConfigurationError, match="mainnet or testnet"): + Bridge(FakeAleo(network_name="devnet")) + empty = Registry(DEFAULT_REGISTRY.version, DEFAULT_REGISTRY.chains(environment="testnet"), + DEFAULT_REGISTRY.assets(environment="testnet"), DEFAULT_REGISTRY.routes(environment="testnet")) + with pytest.raises(ConfigurationError, match="no chains for mainnet"): + Bridge(fake_aleo, registry=empty) + with pytest.raises(ConfigurationError, match="default_account"): + Bridge(FakeAleo(default_account=False)).aleo_address() + + +def test_eth_and_sol_properties_before_plans_2_and_3(fake_aleo): + bridge = Bridge(fake_aleo) + with pytest.raises(ConfigurationError, match="ethereum="): + bridge.eth + with pytest.raises(ConfigurationError, match="solana="): + bridge.sol + with pytest.raises(MissingExtraError, match="aleo-bridge-sdk\\[evm\\]"): # plan 2 replaces: real Ethereum wraps a bare Web3 + Bridge(fake_aleo, ethereum=object()).eth + with pytest.raises(MissingExtraError, match="aleo-bridge-sdk\\[solana\\]"): + Bridge(fake_aleo, solana=object()).sol + + +def test_program_cache_mapping_value_and_call_registration(fake_aleo): + fake_aleo.imports = {"hyp_warp_token_wbtc_v2.aleo": ["token_registry.aleo", "hyp_mailbox.aleo"], "hyp_mailbox.aleo": ["token_registry.aleo"]} + bridge = Bridge(fake_aleo) + assert bridge.program("credits.aleo") is bridge.program("credits.aleo") and fake_aleo.fetched.count("credits.aleo") == 1 + assert bridge.mapping_value("credits.aleo", "account", SIGNER) == "2392443u64" + assert bridge.mapping_value("credits.aleo", "account", "aleo1nobody") is None + fake_aleo.mappings["credits.aleo"]["account"]["aleo1null"] = "null" + fake_aleo.mappings["credits.aleo"]["account"]["aleo1quoted"] = '"7u64"' + assert bridge.mapping_value("credits.aleo", "account", "aleo1null") is None + assert bridge.mapping_value("credits.aleo", "account", "aleo1quoted") == "7u64" + call = bridge._call("hyp_warp_token_wbtc_v2.aleo", "transfer_remote", ["1u128"], lambda tx, outs: tx) + assert isinstance(call, AleoCall) and fake_aleo.registered == [] + call.simulate() + assert fake_aleo.registered == ["token_registry.aleo", "hyp_mailbox.aleo", "hyp_warp_token_wbtc_v2.aleo"] # dependencies first, root last + + +def test_amount_helpers_and_privacy_delegation(fake_aleo): + bridge = Bridge(fake_aleo) + assert bridge.to_atomic("0.001", "aleo/wbtc") == 100_000 and bridge.from_atomic(100_000, ("aleo", "wbtc")) == "0.001" + assert bridge.to_atomic("1", DEFAULT_REGISTRY.asset("ethereum/usdc")) == 1_000_000 + assert bridge.shield("aleo/eth", amount="1").function_name == "shield" + assert bridge.unshield("aleo/usdcx", amount="2.5").function_name == "transfer_private_to_public" + + +def test_status_reads_every_aleo_asset_balance(fake_aleo): + status = Bridge(fake_aleo).status() + assert isinstance(status, BridgeStatus) and status.environment == "mainnet" and status.registry_version == DEFAULT_REGISTRY.version + assert status.pending == [] and len(status.chains) == 1 + chain = status.chains[0] + assert (chain.chain_id, chain.address, chain.can_sign) == ("aleo", SIGNER, True) + assert chain.balances == {"aleo/aleo": 2392443, "aleo/usdcx": 1000000, "aleo/eth": 0, "aleo/wbtc": 10000, + "aleo/usdt": 0, "aleo/sol": 0, "aleo/usad": 0} + assert "arc20_usdt.aleo" in fake_aleo.fetched and "usad_stablecoin.aleo" in fake_aleo.fetched + unsigned = Bridge(FakeAleo(mappings=default_mappings(), default_account=False)).status().chains[0] + assert (unsigned.address, unsigned.can_sign) == (None, False) and set(unsigned.balances.values()) == {0} + + +def test_from_env_builds_aleo_only(monkeypatch, fake_aleo): + captured = {} + + def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None): + captured.update(endpoint=endpoint, network=network, private_key=private_key, api_key=api_key, consumer_id=consumer_id) + return FakeAleo(mappings=default_mappings(), network_name=network) + + monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) + for var in ("BRIDGE_PRIVATE_KEY", "ALEO_ENDPOINT", "ALEO_NETWORK", "ALEO_API_KEY", "ALEO_CONSUMER_ID", "EVM_PRIVATE_KEY", + "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + with pytest.raises(ConfigurationError, match="BRIDGE_PRIVATE_KEY"): + Bridge.from_env() + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") + bridge = Bridge.from_env() + assert captured == {"endpoint": "https://edge.provable.com/api", "network": "mainnet", "private_key": "APrivateKey1zkpTest", + "api_key": None, "consumer_id": None} + assert bridge.environment == "mainnet" and bridge.ethereum is None and bridge.solana is None and bridge.checkpoints is None + monkeypatch.setenv("ALEO_NETWORK", "testnet") + monkeypatch.setenv("ALEO_ENDPOINT", "https://api.provable.com/v2") + monkeypatch.setenv("ALEO_API_KEY", "k") + monkeypatch.setenv("ALEO_CONSUMER_ID", "c") + assert Bridge.from_env().environment == "testnet" + assert (captured["endpoint"], captured["api_key"], captured["consumer_id"]) == ("https://api.provable.com/v2", "k", "c") + marker = object() + assert Bridge.from_env(ethereum=None, solana=None).ethereum is None + assert Bridge.from_env(checkpoints=marker).checkpoints is marker + with pytest.raises(TypeError, match="unexpected"): + Bridge.from_env(w3=marker) + + +def test_from_env_side_chain_variables(monkeypatch): + monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *a, **k: FakeAleo(mappings=default_mappings())) + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("EVM_PRIVATE_KEY", "0x" + "11" * 32) + with pytest.raises(ConfigurationError, match="both or neither"): + Bridge.from_env() + monkeypatch.setenv("ETHEREUM_RPC_URL", "https://eth.example") + with pytest.raises(MissingExtraError, match="evm"): # plan 2 makes this construct an Ethereum connection + Bridge.from_env() + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.delenv("ETHEREUM_RPC_URL") + monkeypatch.setenv("SOLANA_PRIVATE_KEY", "5" * 88) + with pytest.raises(MissingExtraError, match="solana"): # plan 3 makes this construct a Solana connection + Bridge.from_env() + monkeypatch.delenv("SOLANA_PRIVATE_KEY") + monkeypatch.setenv("BRIDGE_CHECKPOINT_DIR", "/tmp/cp") + with pytest.raises(ConfigurationError, match="plan 4"): # plan 4 wires FileCheckpointStore + Bridge.from_env() + + +def test_build_aleo_is_local_only(): + from aleo import testnet as net + from aleo_bridge.client import build_aleo + key = net.PrivateKey.random() + aleo = build_aleo("https://edge.provable.com/api", "testnet", str(key)) + assert aleo.network_name == "testnet" and str(aleo.default_account.address) == str(key.address) + + +def test_from_profile_uses_profile_and_wires_no_side_chains(tmp_path, monkeypatch): + captured = {} + + def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None): + captured.update(endpoint=endpoint, network=network, private_key=private_key) + return FakeAleo(mappings=default_mappings(), network_name=network) + + monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) + for var in ("BRIDGE_PRIVATE_KEY", "BRIDGE_PRIVATE_KEY_FILE", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "home")) + bridge = Bridge.from_profile(network="testnet", endpoint="https://api.provable.com/v2") + assert bridge.profile is not None and bridge.profile.home == tmp_path / "home" + assert captured == {"endpoint": "https://api.provable.com/v2", "network": "testnet", "private_key": bridge.profile.private_key} + assert bridge.environment == "testnet" and bridge.ethereum is None and bridge.solana is None + assert bridge.checkpoints is None # plan 4 binds FileCheckpointStore(profile.checkpoint_dir) + assert bridge.profile.checkpoint_dir.is_dir() + marker = object() + assert Bridge.from_profile(ethereum=marker).ethereum is marker + + +def test_cli_lists_routes_and_assets(capsys): + assert cli.main(["routes"]) == 0 + routes = json.loads(capsys.readouterr().out) + assert len(routes) == 22 and routes[0] == "xreserve:ethereum/usdc->aleo/usdcx" + assert cli.main(["assets"]) == 0 + assert len(json.loads(capsys.readouterr().out)) == 19 + assert cli.main(["bogus"]) == 2 diff --git a/bridge-sdk/tests/test_profile.py b/bridge-sdk/tests/test_profile.py new file mode 100644 index 00000000..416589f2 --- /dev/null +++ b/bridge-sdk/tests/test_profile.py @@ -0,0 +1,48 @@ +import json +import os +import stat + +import pytest + +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.profile import DEFAULT_ENDPOINT, Profile + + +def test_profile_created_once_with_private_mode(tmp_path, monkeypatch): + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + profile = Profile.load_or_create(tmp_path / "home") + assert profile.address.startswith("aleo1") and len(profile.address) == 63 + assert profile.private_key.startswith("APrivateKey1") and profile.network == "mainnet" and profile.endpoint == DEFAULT_ENDPOINT + assert stat.S_IMODE(os.stat(tmp_path / "home" / "profile.json").st_mode) == 0o600 + again = Profile.load_or_create(tmp_path / "home", network="testnet", endpoint="https://other.example") + assert (again.address, again.network, again.endpoint) == (profile.address, "mainnet", DEFAULT_ENDPOINT) # creation-only args + data = json.loads((tmp_path / "home" / "profile.json").read_text()) + assert set(data) == {"address", "private_key", "network", "endpoint"} + assert profile.checkpoint_dir == tmp_path / "home" / "checkpoints" and profile.checkpoint_dir.is_dir() + + +def test_profile_imports_key_from_env_or_file(tmp_path, monkeypatch): + from aleo import testnet as net + key = net.PrivateKey.random() + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", str(key)) + assert Profile.load_or_create(tmp_path / "a", network="testnet").address == str(key.address) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY") + key_file = tmp_path / "key.txt" + key_file.write_text(f"{key}\n") + monkeypatch.setenv("BRIDGE_PRIVATE_KEY_FILE", str(key_file)) + assert Profile.load_or_create(tmp_path / "b", network="testnet").address == str(key.address) + + +def test_default_home_and_tilde_expansion(tmp_path, monkeypatch): + monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "x")) + assert Profile.default_home() == tmp_path / "x" + monkeypatch.delenv("ALEO_BRIDGE_HOME") + assert Profile.default_home().name == ".aleo-bridge" + monkeypatch.setenv("HOME", str(tmp_path)) + assert Profile.load_or_create("~/p", network="testnet").home == tmp_path / "p" + + +def test_profile_rejects_unknown_network(tmp_path): + with pytest.raises(ConfigurationError, match="network"): + Profile.load_or_create(tmp_path / "bad", network="devnet") From 77992bd46d4bb88d3537eea71241440db6514fdf Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 10:44:52 -0400 Subject: [PATCH 17/94] fix(bridge-sdk): read the USDCx freeze list from usdcx_freezelist.aleo and verify the root on chain --- bridge-sdk/python/aleo_bridge/freezelist.py | 61 +++++++++++++++++---- bridge-sdk/tests/test_freezelist.py | 47 ++++++++++++++-- 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index af3fdc76..286bb534 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -21,8 +21,16 @@ ZERO_ADDRESS = "aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc" DEFAULT_DEPTH = 15 # TS getSiblingPath default; tree capacity 2**(depth-1) leaves PROOF_SIBLINGS = 16 # struct MerkleProof { siblings: [field; 16u32], leaf_index: u32 } -FREEZE_LIST_MAPPING = "freeze_list" # u32 => address +FREEZE_LIST_MAPPING = "freeze_list" # address => bool (frozen flag, NOT the list) FREEZE_LIST_LAST_INDEX_MAPPING = "freeze_list_last_index" # bool => u32, keyed "true" +FREEZE_LIST_INDEX_MAPPING = "freeze_list_index" # u32 => address (the ordered list; [0u32] = zero-address sentinel) +FREEZE_LIST_ROOT_MAPPING = "freeze_list_root" # u8 => field, keyed "1u8" (current) / "2u8" (previous) +CURRENT_ROOT_KEY = "1u8" +# Token program -> its freeze-list program, for when Bridge.program(token).imports is unavailable. +FREEZE_LIST_PROGRAMS = { + "usdcx_stablecoin.aleo": "usdcx_freezelist.aleo", + "test_usdcx_stablecoin.aleo": "test_usdcx_freezelist.aleo", +} EMPTY_TREE_ROOT = 3642222252059314292809609689035560016959342421640560347114299934615987159853 _EMPTY_PROOF = "{ siblings: [" + ", ".join(["0field"] * PROOF_SIBLINGS) + "], leaf_index: 1u32 }" EMPTY_MERKLE_PROOF_PAIR = f"[{_EMPTY_PROOF}, {_EMPTY_PROOF}]" @@ -110,31 +118,62 @@ class FreezeList: def __init__(self, bridge: "Bridge") -> None: self._bridge = bridge + def freeze_list_program(self, token_program: str) -> str: + """The freeze-list program backing *token_program* (usually a token program that imports it).""" + if token_program.endswith("freezelist.aleo"): + return token_program + try: + imports = self._bridge.program(token_program).imports + except Exception: + imports = [] + for dep in imports: + if str(dep).endswith("freezelist.aleo"): + return str(dep) + fallback = FREEZE_LIST_PROGRAMS.get(token_program) + if fallback: + return fallback + raise ConfigurationError(f"{token_program} has no freeze-list program; pass merkle_proof explicitly") + def leaves(self, program: str) -> list[str]: - """Frozen addresses from ``program``'s ``freeze_list`` mapping (indices 0..last inclusive); ``[]`` when none.""" - last = self._bridge.mapping_value(program, FREEZE_LIST_LAST_INDEX_MAPPING, "true") + """Frozen addresses from *program*'s freeze-list ``freeze_list_index`` mapping (0..last inclusive), + with the zero-address sentinel dropped; ``[]`` when the list is empty/unreadable.""" + fl_program = self.freeze_list_program(program) + last = self._bridge.mapping_value(fl_program, FREEZE_LIST_LAST_INDEX_MAPPING, "true") if last is None: return [] try: count = int(last.removesuffix("u32")) except ValueError as exc: - raise ConfigurationError(f"{program}/{FREEZE_LIST_LAST_INDEX_MAPPING} returned {last!r}, expected a u32") from exc + raise ConfigurationError(f"{fl_program}/{FREEZE_LIST_LAST_INDEX_MAPPING} returned {last!r}, expected a u32") from exc addresses = [] for index in range(count + 1): - value = self._bridge.mapping_value(program, FREEZE_LIST_MAPPING, f"{index}u32") + value = self._bridge.mapping_value(fl_program, FREEZE_LIST_INDEX_MAPPING, f"{index}u32") if value and value != ZERO_ADDRESS: addresses.append(value) return addresses + def _verified_tree(self, fl_program: str, leaves: list[str]) -> list[int]: + tree = build_tree(generate_leaves(leaves), self._bridge.network) + on_chain_root = self._bridge.mapping_value(fl_program, FREEZE_LIST_ROOT_MAPPING, CURRENT_ROOT_KEY) + if on_chain_root is not None: + computed_root = f"{tree[-1]}field" + if computed_root != on_chain_root: + raise ConfigurationError( + f"computed freeze-list root {computed_root} != on-chain root {on_chain_root} for {fl_program}; " + "refusing to build a proof") + return tree + def tree(self, program: str) -> list[int]: - return build_tree(generate_leaves(self.leaves(program)), self._bridge.network) + """Merkle tree over *program*'s frozen addresses, verified against the on-chain root when readable.""" + return self._verified_tree(self.freeze_list_program(program), self.leaves(program)) def exclusion_proof(self, address: str, program: str) -> str: """``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty.""" + fl_program = self.freeze_list_program(program) leaves = self.leaves(program) + tree = self._verified_tree(fl_program, leaves) # verifies the on-chain root, empty list included if not leaves: return EMPTY_MERKLE_PROOF_PAIR - tree = build_tree(generate_leaves(leaves), self._bridge.network) count = (len(tree) + 1) // 2 target = address_to_field_int(address) if target in tree[:count]: @@ -144,6 +183,8 @@ def exclusion_proof(self, address: str, program: str) -> str: (sibling_path(tree, right, PROOF_SIBLINGS), right)) -__all__ = ["DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", "FREEZE_LIST_LAST_INDEX_MAPPING", - "FREEZE_LIST_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", "address_to_field_int", - "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", "leaf_indices", "sibling_path"] +__all__ = ["CURRENT_ROOT_KEY", "DEFAULT_DEPTH", "EMPTY_MERKLE_PROOF_PAIR", "EMPTY_TREE_ROOT", + "FREEZE_LIST_INDEX_MAPPING", "FREEZE_LIST_LAST_INDEX_MAPPING", "FREEZE_LIST_MAPPING", + "FREEZE_LIST_PROGRAMS", "FREEZE_LIST_ROOT_MAPPING", "PROOF_SIBLINGS", "ZERO_ADDRESS", "FreezeList", + "address_to_field_int", "build_tree", "format_merkle_proof", "generate_leaves", "hash_two", + "leaf_indices", "sibling_path"] diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py index a9be1975..bbdd6b43 100644 --- a/bridge-sdk/tests/test_freezelist.py +++ b/bridge-sdk/tests/test_freezelist.py @@ -10,7 +10,8 @@ B_FIELD = 1295133970529764960316948294624974168921228814652993007266766481909235735940 RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" ZERO = fl.ZERO_ADDRESS -PROGRAM = "usdcx_stablecoin.aleo" +PROGRAM = "usdcx_stablecoin.aleo" # the TOKEN program, as callers (privacy.py, xreserve.py) pass it +FREEZE_LIST_PROGRAM = "usdcx_freezelist.aleo" # the program that actually holds the freeze list EMPTY_ONE = "{ siblings: [" + ", ".join(["0field"] * 16) + "], leaf_index: 1u32 }" @@ -84,10 +85,10 @@ def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): def test_freezelist_reads_mappings_and_builds_proof(bridge): - mappings = bridge.aleo.mappings[PROGRAM] + mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) assert bridge.freezelist.leaves(PROGRAM) == [] and bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR mappings["freeze_list_last_index"] = {"true": "1u32"} - mappings["freeze_list"] = {"0u32": A, "1u32": ZERO} + mappings["freeze_list_index"] = {"0u32": A, "1u32": ZERO} assert bridge.freezelist.leaves(PROGRAM) == [A] # zero address filtered assert bridge.freezelist.tree(PROGRAM)[:2] == [0, A_FIELD] one = "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" @@ -97,7 +98,43 @@ def test_freezelist_reads_mappings_and_builds_proof(bridge): def test_freezelist_last_index_parsing_is_not_rstrip(bridge): - mappings = bridge.aleo.mappings[PROGRAM] + mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) mappings["freeze_list_last_index"] = {"true": "12u32"} - mappings["freeze_list"] = {f"{i}u32": ZERO for i in range(12)} | {"12u32": B} + mappings["freeze_list_index"] = {f"{i}u32": ZERO for i in range(12)} | {"12u32": B} assert bridge.freezelist.leaves(PROGRAM) == [B] + + +def test_freeze_list_program_resolves_via_imports(bridge): + bridge.aleo.imports["usdcx_stablecoin.aleo"] = ["credits.aleo", FREEZE_LIST_PROGRAM] + assert bridge.freezelist.freeze_list_program(PROGRAM) == FREEZE_LIST_PROGRAM + + +def test_freeze_list_program_resolves_via_fallback_table_when_imports_unavailable(bridge): + # default fixture sets no imports for usdcx_stablecoin.aleo -> falls back to the static table + assert bridge.freezelist.freeze_list_program(PROGRAM) == FREEZE_LIST_PROGRAM + assert bridge.freezelist.freeze_list_program("test_usdcx_stablecoin.aleo") == "test_usdcx_freezelist.aleo" + assert bridge.freezelist.freeze_list_program(FREEZE_LIST_PROGRAM) == FREEZE_LIST_PROGRAM # already a freezelist program + + +def test_freeze_list_program_raises_for_unknown_program(bridge): + with pytest.raises(ConfigurationError, match="no freeze-list program"): + bridge.freezelist.freeze_list_program("arc20_wbtc.aleo") + + +def test_leaves_reads_from_freeze_list_program_not_token_program(bridge): + # the token program's own mappings are deliberately wrong, to prove they are never consulted + bridge.aleo.mappings[PROGRAM]["freeze_list_last_index"] = {"true": "0u32"} + bridge.aleo.mappings[PROGRAM]["freeze_list_index"] = {"0u32": A} + fl_mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) + fl_mappings["freeze_list_last_index"] = {"true": "1u32"} + fl_mappings["freeze_list_index"] = {"0u32": ZERO, "1u32": B} # index 0 is the zero-address sentinel + assert bridge.freezelist.leaves(PROGRAM) == [B] + + +def test_exclusion_proof_verifies_onchain_root(bridge): + fl_mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) + fl_mappings["freeze_list_root"] = {"1u8": "123field"} + with pytest.raises(ConfigurationError, match="freeze-list root"): + bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + fl_mappings["freeze_list_root"] = {"1u8": f"{fl.EMPTY_TREE_ROOT}field"} + assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR From 2bfd6b7be4e65c7ba626391e2d9ccc2ddf67e8bd Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 10:47:33 -0400 Subject: [PATCH 18/94] fix(bridge-sdk): mapping_value returns None for a missing program --- bridge-sdk/python/aleo_bridge/client.py | 13 +++++++++++-- bridge-sdk/tests/conftest.py | 8 +++++++- bridge-sdk/tests/test_client.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 70631958..5b026152 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -196,8 +196,17 @@ def program(self, program_id: str) -> Any: return self._programs[program_id] def mapping_value(self, program_id: str, mapping: str, key: str) -> str | None: - """Mapping value as a string, or None when the key is absent/null.""" - value = self.program(program_id).mapping(mapping).get(key) + """Mapping value as a string, or None when the key is absent/null, or *program_id* does not exist. + + A missing program is reported through the return value here, not an exception, so status()/freezelist + reads over a program that may not be deployed (yet) degrade to "no data" instead of raising. Callers + that want the error can still get it from ``program(program_id)`` directly. + """ + from aleo.facade.errors import ProgramNotFound + try: + value = self.program(program_id).mapping(mapping).get(key) + except ProgramNotFound: + return None if value is None: return None text = str(value).strip().strip('"') diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 9e07a501..5008b78c 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -115,6 +115,9 @@ def __init__(self, aleo: "FakeAleo") -> None: self._aleo = aleo def get(self, program_id: str) -> FakeProgram: + if program_id in self._aleo.missing_programs: + from aleo.facade.errors import ProgramNotFound + raise ProgramNotFound(program_id) self._aleo.fetched.append(program_id) return FakeProgram(self._aleo, program_id) @@ -167,13 +170,16 @@ class FakeAleo: """Facade stand-in: mappings keyed program → mapping → key; records; network; process; recorders.""" def __init__(self, mappings: dict | None = None, records: list[dict] | None = None, - network_name: str = "mainnet", default_account: Any = None, imports: dict | None = None) -> None: + network_name: str = "mainnet", default_account: Any = None, imports: dict | None = None, + missing_programs: "set[str] | None" = None) -> None: self.network_name = network_name self.default_account = FakeAccount() if default_account is None else default_account self.mappings = mappings or {} # ``records`` is the module (aleo.records.find); the rows it returns live in ``record_rows``. self.record_rows = records if records is not None else [{"program": "usdcx_stablecoin.aleo", "record_plaintext": USDCX_RECORD}] self.imports = imports or {} + # program ids that raise ProgramNotFound from programs.get() instead of returning a FakeProgram. + self.missing_programs = set(missing_programs or ()) self.calls: list = [] self.simulated: list = [] self.delegated: list = [] diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index c1849495..1897356c 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -2,6 +2,8 @@ import pytest +from aleo.facade.errors import ProgramNotFound + from aleo_bridge import Bridge, __main__ as cli from aleo_bridge._calls import AleoCall from aleo_bridge.errors import ConfigurationError, MissingExtraError @@ -65,6 +67,14 @@ def test_program_cache_mapping_value_and_call_registration(fake_aleo): assert fake_aleo.registered == ["token_registry.aleo", "hyp_mailbox.aleo", "hyp_warp_token_wbtc_v2.aleo"] # dependencies first, root last +def test_mapping_value_returns_none_for_missing_program(fake_aleo): + fake_aleo.missing_programs = {"missing.aleo"} + bridge = Bridge(fake_aleo) + assert bridge.mapping_value("missing.aleo", "balances", SIGNER) is None + with pytest.raises(ProgramNotFound): + bridge.program("missing.aleo") + + def test_amount_helpers_and_privacy_delegation(fake_aleo): bridge = Bridge(fake_aleo) assert bridge.to_atomic("0.001", "aleo/wbtc") == 100_000 and bridge.from_atomic(100_000, ("aleo", "wbtc")) == "0.001" From 326175dcd040bbb248e237612c0dd3d3a3c7f5a2 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 10:53:14 -0400 Subject: [PATCH 19/94] test(bridge-sdk): gated read-only live checks (IGP quotes, mailbox and nullifier reads, registry drift) and README --- bridge-sdk/README.md | 47 +++++++- bridge-sdk/tests/live/__init__.py | 0 bridge-sdk/tests/live/conftest.py | 17 +++ bridge-sdk/tests/live/test_aleo_reads.py | 138 +++++++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/tests/live/__init__.py create mode 100644 bridge-sdk/tests/live/conftest.py create mode 100644 bridge-sdk/tests/live/test_aleo_reads.py diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index c767fad2..92e45ecd 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -1,4 +1,47 @@ # aleo-bridge-sdk -Python SDK for bridging assets between Aleo, Ethereum and Solana (Hyperlane warp routes, Circle xReserve). -`pip install aleo-bridge-sdk` → `from aleo_bridge import Bridge`. Expanded in plan 1 Task 12 and plan 4. +Python SDK for bridging assets between Aleo, Ethereum and Solana over the reviewed Hyperlane warp +routes and Circle xReserve deployments — a port of veil's `@provablehq/aleo-bridge-sdk` 0.1.0 into +the web3.py-style verb structure of `aleo-sdk`. + +## Install + + pip install aleo-bridge-sdk # Aleo legs only + pip install 'aleo-bridge-sdk[evm]' # + Ethereum (web3, eth-account) + pip install 'aleo-bridge-sdk[solana]' # + Solana (solders, solana) + +## Use (Aleo side) + + from aleo import Aleo, HTTPProvider + from aleo_bridge import Bridge + + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) + aleo.default_account = aleo.account.from_private_key(key) + bridge = Bridge(aleo) # or Bridge.from_env() / Bridge.from_profile() + + bridge.status() # addresses + public balances, read-only + bridge.hyperlane.quote_gas_payment("aleo/wbtc") + call = bridge.hyperlane.transfer_remote("aleo/wbtc", "0xRecipient", amount="0.0001", as_signer=True) + call.simulate() # local authorization, nothing sent + receipt = call.delegate() # DPS proves, fee master pays, broadcast + bridge.xreserve.burn("0xRecipient", amount="2.5") # private USDCx → USDC + bridge.shield("aleo/eth", amount="0.01"); bridge.unshield("aleo/usdcx", amount="2.5") + +Reads return values; writes return an `AleoCall` with `simulate() / prove() / transact() / delegate()`. +Lifecycle verbs (`quote → execute → wait`, `recover/resume/complete`), Ethereum and Solana origins, +and the agent/MCP surface arrive in the following plans. + +## Environment + +`BRIDGE_PRIVATE_KEY` (required by `from_env`), `ALEO_ENDPOINT` (default `https://edge.provable.com/api`), +`ALEO_NETWORK` (`mainnet`|`testnet`), `ALEO_API_KEY`/`ALEO_CONSUMER_ID` (legacy hosts), +`EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`), `BRIDGE_CHECKPOINT_DIR`. +Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo key (mode 600). + +## Tests + + cd bridge-sdk && .venv/bin/python -m pytest -q # hermetic + BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live -q # read-only mainnet checks + BRIDGE_LIVE_READS=1 BRIDGE_LIVE_SIMULATE=1 .venv/bin/python -m pytest -m live tests/live -q + +Literals and vectors: `docs/veil-brief.md`. diff --git a/bridge-sdk/tests/live/__init__.py b/bridge-sdk/tests/live/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bridge-sdk/tests/live/conftest.py b/bridge-sdk/tests/live/conftest.py new file mode 100644 index 00000000..b1962413 --- /dev/null +++ b/bridge-sdk/tests/live/conftest.py @@ -0,0 +1,17 @@ +import os + +import pytest + +from aleo_bridge import Bridge + + +@pytest.fixture(scope="session") +def live_bridge() -> Bridge: + if os.environ.get("BRIDGE_LIVE_READS") != "1": + pytest.skip("set BRIDGE_LIVE_READS=1 (and BRIDGE_PRIVATE_KEY) to run read-only live checks") + if not os.environ.get("BRIDGE_PRIVATE_KEY"): + pytest.skip("BRIDGE_PRIVATE_KEY is required for Bridge.from_env()") + bridge = Bridge.from_env() + if bridge.environment != "mainnet": + pytest.skip("live read checks target the mainnet routes; unset ALEO_NETWORK or set it to mainnet") + return bridge diff --git a/bridge-sdk/tests/live/test_aleo_reads.py b/bridge-sdk/tests/live/test_aleo_reads.py new file mode 100644 index 00000000..df483ff3 --- /dev/null +++ b/bridge-sdk/tests/live/test_aleo_reads.py @@ -0,0 +1,138 @@ +"""Read-only checks against mainnet: IGP quotes, mailbox/nullifier reads, registry-vs-chain drift, mapping names. +A drift failure means the deployment moved since veil's review — STOP and report; never edit the registry to pass.""" +import os +import re + +import pytest + +from aleo_bridge import encoding as enc +from aleo_bridge.client import BALANCE_MAPPING, balance_program +from aleo_bridge.freezelist import CURRENT_ROOT_KEY, FREEZE_LIST_LAST_INDEX_MAPPING, FREEZE_LIST_MAPPING, build_tree, generate_leaves +from aleo_bridge.hyperlane import compute_gas_payment, gas_config_key, parse_gas_config + +pytestmark = pytest.mark.live + +MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" +ALEO_ORIGIN = ["aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol"] + + +def _squash(text: str) -> str: + return re.sub(r"\s+", "", text) + + +@pytest.mark.parametrize("asset", ALEO_ORIGIN) +def test_igp_quote_is_a_positive_u64(live_bridge, asset): + quote = live_bridge.hyperlane.quote_gas_payment(asset) + route = live_bridge.hyperlane.outbound_route(asset) + assert quote.route_id == route.id + assert 0 < quote.payment_microcredits < 2**64 and quote.gas_price > 0 and quote.exchange_rate > 0 + assert quote.gas_limit == int(route.metadata["aleoRemoteRouterGas"]) + # Self-consistency: recompute the exact u64 from a raw mapping read taken independently of quote_gas_payment. + literal = live_bridge.mapping_value(route.meta_str("aleoHookManagerProgram"), "destination_gas_configs", + gas_config_key(route)) + assert literal is not None, f"destination_gas_configs is missing on chain for {route.id}" + config = parse_gas_config(literal) + assert config == {"gas_overhead": quote.gas_overhead, "gas_price": quote.gas_price, "exchange_rate": quote.exchange_rate} + recomputed = compute_gas_payment(gas_limit=quote.gas_limit, gas_overhead=config["gas_overhead"], + gas_price=config["gas_price"], exchange_rate=config["exchange_rate"]) + assert recomputed == quote.payment_microcredits + + +def test_mailbox_deliveries_vector(live_bridge, record_property): + delivered = live_bridge.hyperlane.is_delivered(MESSAGE_ID) + record_property("hyperlane_is_delivered_pinned_message", delivered) + assert delivered is True + assert live_bridge.hyperlane.is_delivered("0x" + "00" * 32) is False + + +def test_usdcx_bridge_nullifier_read(live_bridge): + assert live_bridge.xreserve.is_delivered(bytes(32)) is False # the zero nonce was never deposited + assert live_bridge.xreserve.inbound_route().metadata["bridgeProgram"] == "usdcx_bridge_v2.aleo" + + +@pytest.mark.parametrize("asset", ALEO_ORIGIN) +def test_registry_matches_deployed_warp_route_state(live_bridge, asset, record_property): + route = live_bridge.hyperlane.outbound_route(asset) + program = route.metadata["aleoRouterProgram"] + app = _squash(live_bridge.mapping_value(program, "app_metadata", "true") or "") + assert f"token_owner:{route.metadata['aleoTokenOwner']}" in app + assert f"token_id:{route.metadata['aleoTokenId']}" in app + assert f"local_decimals:{route.metadata['aleoLocalDecimals']}u8" in app + assert f"remote_decimals:{route.metadata['aleoRemoteDecimals']}u8" in app + raw_router = live_bridge.mapping_value(program, "remote_routers", f"{route.metadata['aleoDestinationDomain']}u32") + record_property(f"remote_routers_{asset}", raw_router) + if raw_router is None: + pytest.skip(f"{program}/remote_routers[{route.metadata['aleoDestinationDomain']}u32] did not parse; logged as None") + router = _squash(raw_router) + assert f"gas:{route.metadata['aleoRemoteRouterGas']}u128" in router + assert _squash(route.metadata["aleoRemoteRouterRecipient"]) in router + + +def test_mailbox_state_matches_registry(live_bridge): + route = live_bridge.hyperlane.outbound_route("aleo/eth") + mailbox = _squash(live_bridge.mapping_value(route.metadata["aleoMailboxProgram"], "mailbox", "true") or "") + assert f"default_hook:{route.metadata['aleoMailboxDefaultHook']}" in mailbox + assert f"required_hook:{route.metadata['aleoMailboxRequiredHook']}" in mailbox + + +def test_freeze_list_mappings_exist_and_proof_builds(live_bridge): + program_id = "usdcx_stablecoin.aleo" + fl_program = live_bridge.freezelist.freeze_list_program(program_id) + assert fl_program == "usdcx_freezelist.aleo" # the freeze-list mappings live on the freezelist program, not the token + names = live_bridge.program(fl_program).mappings() + assert FREEZE_LIST_MAPPING in names and FREEZE_LIST_LAST_INDEX_MAPPING in names, \ + f"freeze-list mapping names differ on chain: {sorted(names)} — update the two constants in freezelist.py only" + leaves = live_bridge.freezelist.leaves(program_id) + if "freeze_list_root" in names: + root = build_tree(generate_leaves(leaves), "mainnet")[-1] + on_chain_root = live_bridge.mapping_value(fl_program, "freeze_list_root", CURRENT_ROOT_KEY) + assert on_chain_root == f"{root}field", \ + f"leaves={leaves!r} recomputed root {root} != on-chain freeze_list_root[{CURRENT_ROOT_KEY}] {on_chain_root!r}" + # exclusion_proof() independently re-verifies the same on-chain root before building the proof. + proof = live_bridge.freezelist.exclusion_proof(live_bridge.aleo_address(), program_id) + assert proof.count("leaf_index") == 2 and proof.count("field") == 32 + + +def test_balance_mappings_exist_for_every_aleo_asset(live_bridge): + for asset in live_bridge.registry.assets(chain="aleo"): + program = balance_program(asset) + if program is None: + continue + names = live_bridge.program(program).mappings() + assert BALANCE_MAPPING in names, f"{program} declares {sorted(names)}; adjust BALANCE_MAPPING/balance_program in client.py" + + +def test_status_is_read_only_and_complete(live_bridge): + status = live_bridge.status() + assert status.chains[0].address == live_bridge.aleo_address() + assert set(status.chains[0].balances) == {a.id for a in live_bridge.registry.assets(chain="aleo")} + assert status.chains[0].balances["aleo/aleo"] > 0, \ + f"expected the live-test funding key to hold a positive credits balance, got {status.chains[0].balances['aleo/aleo']}" + + +def test_wrapper_program_is_deployed_with_expected_transitions(live_bridge): + assert enc.aleo_program_address("shielded_usdcx_wrapper.aleo", "mainnet") == \ + "aleo183r3zgsr57fwtgk5duzeq9kqdkpmmtfj4k5469ddvm3tcfhhls9szktw82" + functions = live_bridge.program("shielded_usdcx_wrapper.aleo").functions + assert "private_mint" in functions and "private_burn" in functions + bridge_functions = live_bridge.program("usdcx_bridge_v2.aleo").functions + assert "burn_public" in bridge_functions and "burn_public_as_signer" in bridge_functions + for asset in ALEO_ORIGIN: + router = live_bridge.program(live_bridge.hyperlane.outbound_route(asset).metadata["aleoRouterProgram"]).functions + assert "transfer_remote" in router and "transfer_remote_as_signer" in router + + +def test_transfer_remote_simulate(live_bridge): + if os.environ.get("BRIDGE_LIVE_SIMULATE") != "1": + pytest.skip("set BRIDGE_LIVE_SIMULATE=1 to build a local authorization (downloads proving parameters; no broadcast)") + call = live_bridge.hyperlane.transfer_remote("aleo/wbtc", "0x0000000000000000000000000000000000000001", amount_atomic=1, as_signer=True) + assert "amount: " in call.inputs[6] and call.inputs[5] == "1u128" + try: + authorization = call.simulate() + except Exception as exc: # noqa: BLE001 — the facade's own authorization error type varies by binding version + reason = str(exc) + if "balance" in reason.lower() or "insufficient" in reason.lower(): + pytest.xfail(reason=f"authorization failed for lack of a wrapped-WBTC balance on the live-test key: {reason}") + raise + assert authorization.function_name == "transfer_remote_as_signer" + assert live_bridge.aleo.network.get_latest_height() > 0 # sanity: the same client reaches the node; nothing was submitted From a7a45d3eb1c59ad069c15384aa68a5069315d5ff Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 11:02:40 -0400 Subject: [PATCH 20/94] feat(bridge-sdk): version-1 checkpoint allowlist and FileCheckpointStore 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. --- bridge-sdk/python/aleo_bridge/__init__.py | 2 + bridge-sdk/python/aleo_bridge/checkpoint.py | 256 ++++++++++++++++++++ bridge-sdk/tests/test_checkpoint.py | 219 +++++++++++++++++ bridge-sdk/tests/test_client.py | 14 +- 4 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/checkpoint.py create mode 100644 bridge-sdk/tests/test_checkpoint.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 6c4946a8..c5566adf 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -22,6 +22,7 @@ to_progress, ) from ._calls import AleoCall # noqa: E402 +from .checkpoint import Checkpoint, CheckpointStore, FileCheckpointStore, create_checkpoint # noqa: E402 from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList # noqa: E402 @@ -43,4 +44,5 @@ "SolanaHyperlaneQuote", "Status", "Step", "to_progress", "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", + "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", ] diff --git a/bridge-sdk/python/aleo_bridge/checkpoint.py b/bridge-sdk/python/aleo_bridge/checkpoint.py new file mode 100644 index 00000000..6b42d799 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/checkpoint.py @@ -0,0 +1,256 @@ +"""Checkpoints — the allowlisted, versioned recovery record (spec §8, brief §2.10). + +A checkpoint carries the public transfer intent, the route id + registry +version, and the transaction ids already submitted (plus, for Aleo legs, the +exact proved-but-unbroadcast transaction). It never carries keys, record +plaintext, the private-mint secret nonce, attestation bodies, payloads, +message hashes, nonces, or quote internals — ``create_checkpoint`` copies an +allowlist, not ``protocol_state``. +""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol, runtime_checkable + +from .errors import CheckpointInvalidError +from .registry import Registry +from .types import Plan, Receipt + +CHECKPOINT_VERSION = 1 +_DIGITS = re.compile(r"^\d+$") + + +@dataclass(frozen=True) +class Checkpoint: + """Version-1 recovery record. Dict/JSON keys are veil's camelCase names.""" + + version: int + receipt_id: str + intent: dict[str, Any] + route: dict[str, str] + source: dict[str, Any] | None = None + destination: dict[str, Any] | None = None + delivery_verification: dict[str, str] | None = None + + @property + def id(self) -> str: + """The receipt id this checkpoint was created from (store key).""" + return self.receipt_id + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"version": self.version, "receiptId": self.receipt_id, + "intent": self.intent, "route": self.route} + if self.source: + out["source"] = self.source + if self.destination: + out["destination"] = self.destination + if self.delivery_verification: + out["deliveryVerification"] = self.delivery_verification + return out + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2, sort_keys=True) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Checkpoint": + if not isinstance(data, dict) or data.get("version") != CHECKPOINT_VERSION \ + or not isinstance(data.get("intent"), dict) or not isinstance(data.get("route"), dict): + raise CheckpointInvalidError( + "Bridge checkpoint format is invalid or unsupported: expected version 1 " + "with 'intent' and 'route' objects") + source = data.get("source") or None + destination = data.get("destination") or None + receipt_id = data.get("receiptId") or _derive_id(source, destination) + return cls(version=1, receipt_id=receipt_id, intent=dict(data["intent"]), + route=dict(data["route"]), source=source, destination=destination, + delivery_verification=data.get("deliveryVerification") or None) + + @classmethod + def from_json(cls, text: str) -> "Checkpoint": + try: + return cls.from_dict(json.loads(text)) + except json.JSONDecodeError as exc: + raise CheckpointInvalidError(f"Bridge checkpoint is not valid JSON: {exc}") from exc + + +def _derive_id(source: dict[str, Any] | None, destination: dict[str, Any] | None) -> str: + """veil-shaped checkpoints have no receiptId: use the most recent transaction id.""" + for container, keys in ((destination, ("transactionId", "preparedTransaction")), + (source, ("preparedTransaction", "transactionId"))): + for key in keys: + value = (container or {}).get(key) + if isinstance(value, dict) and value.get("transactionId"): + return str(value["transactionId"]) + if isinstance(value, str) and value: + return value + approvals = (source or {}).get("approvalTransactionIds") or [] + if approvals: + return str(approvals[-1]) + raise CheckpointInvalidError( + "Bridge checkpoint contains no submitted or prepared transaction to identify it by") + + +def _validated_prepared(value: Any, what: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise CheckpointInvalidError(f"Bridge receipt contains an invalid {what}") + return value + + +def create_checkpoint(plan: Plan, receipt: Receipt, registry: Registry) -> Checkpoint: + """Reduce *receipt* to the documented recovery fields (allowlist, brief §2.10). + + Raises :class:`CheckpointInvalidError` when the receipt belongs to another + route/protocol or carries malformed approval ids, prepared transactions, + Solana blockhash lifetime, or delivery-verification state. + """ + state = receipt.protocol_state + if receipt.protocol != plan.protocol or state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Bridge receipt does not match the prepared route") + + raw_approvals = state.get("approvalTxIds") + if raw_approvals is not None and (not isinstance(raw_approvals, list) + or any(not isinstance(v, str) for v in raw_approvals)): + raise CheckpointInvalidError("Bridge receipt contains invalid approval transaction identifiers") + approvals = list(raw_approvals or []) + + source_sender = state.get("sourceSender") + if source_sender is not None and not isinstance(source_sender, str): + raise CheckpointInvalidError("Bridge receipt contains an invalid source sender") + sender = plan.sender or source_sender + + prepared = _validated_prepared(state.get("preparedTransaction"), "prepared transaction") + prepared_destination = _validated_prepared(state.get("preparedDestinationTransaction"), + "prepared destination transaction") + + blockhash = state.get("blockhash") + last_valid = state.get("lastValidBlockHeight") + if (blockhash is not None or last_valid is not None) and ( + not isinstance(blockhash, str) or not blockhash + or not isinstance(last_valid, str) or not _DIGITS.match(last_valid)): + raise CheckpointInvalidError("Bridge receipt contains an invalid Solana blockhash lifetime") + + before = state.get("destinationBalanceBeforeAtomic") + expected = state.get("expectedDestinationIncreaseAtomic") + if (before is not None or expected is not None) and ( + not isinstance(before, str) or not _DIGITS.match(before) + or not isinstance(expected, str) or not _DIGITS.match(expected)): + raise CheckpointInvalidError( + "Bridge receipt contains invalid destination balance verification state") + + source: dict[str, Any] | None = None + if approvals or receipt.source_tx_id or prepared: + source = {} + if approvals: + source["approvalTransactionIds"] = approvals + if receipt.source_tx_id: + source["transactionId"] = receipt.source_tx_id + if isinstance(state.get("hookData"), str): + source["hookData"] = state["hookData"] + if isinstance(blockhash, str) and isinstance(last_valid, str): + source["blockhash"] = blockhash + source["lastValidBlockHeight"] = last_valid + if prepared is not None: + source["preparedTransaction"] = {"transactionId": receipt.id, + "serializedTransaction": prepared} + + destination: dict[str, Any] | None = None + if receipt.destination_tx_id or prepared_destination is not None: + destination = {} + if receipt.destination_tx_id: + destination["transactionId"] = receipt.destination_tx_id + if prepared_destination is not None: + destination["preparedTransaction"] = {"transactionId": receipt.id, + "serializedTransaction": prepared_destination} + + src_asset = registry.asset(plan.source_asset_id) + dst_asset = registry.asset(plan.destination_asset_id) + intent: dict[str, Any] = { + "source": {"chain": src_asset.chain_id, "asset": src_asset.key}, + "destination": {"chain": dst_asset.chain_id, "asset": dst_asset.key}, + "bridgeProtocol": plan.protocol, + "amount": plan.amount, + "recipient": plan.recipient, + } + if sender: + intent["sender"] = sender + if dst_asset.locator is not None and dst_asset.locator.kind == "aleo-program": + intent["mintMode"] = plan.mint_mode + + verification = ({"balanceBeforeAtomic": before, "expectedIncreaseAtomic": expected} + if isinstance(before, str) and isinstance(expected, str) else None) + return Checkpoint(version=CHECKPOINT_VERSION, receipt_id=receipt.id, intent=intent, + route={"id": plan.route_id, "registryVersion": plan.registry_version}, + source=source, destination=destination, delivery_verification=verification) + + +@runtime_checkable +class CheckpointStore(Protocol): + """Where checkpoints live between processes. Implement all four methods.""" + + def save(self, checkpoint: Checkpoint) -> None: ... + def load(self, checkpoint_id: str) -> Checkpoint | None: ... + def list(self) -> list[Checkpoint]: ... + def delete(self, checkpoint_id: str) -> None: ... + + +_UNSAFE = re.compile(r"[^A-Za-z0-9_-]") + + +class FileCheckpointStore: + """One ``.json`` per receipt id under *directory*; mode 0600; atomic rename. + + ``list()`` returns oldest-first by mtime. ``delete()`` of a missing id is a + no-op. Ids are sanitized for the filesystem; the stored ``receiptId`` keeps + the original. + """ + + def __init__(self, directory: Path | str) -> None: + self.directory = Path(directory) + self.directory.mkdir(parents=True, exist_ok=True) + + def _path(self, checkpoint_id: str) -> Path: + safe = _UNSAFE.sub("_", checkpoint_id) or "_" + return self.directory / f"{safe}.json" + + def save(self, checkpoint: Checkpoint) -> None: + target = self._path(checkpoint.id) + fd, tmp = tempfile.mkstemp(dir=self.directory, prefix=".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(checkpoint.to_json()) + fh.write("\n") + os.chmod(tmp, 0o600) + os.replace(tmp, target) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + def load(self, checkpoint_id: str) -> Checkpoint | None: + path = self._path(checkpoint_id) + if not path.exists(): + return None + return Checkpoint.from_json(path.read_text(encoding="utf-8")) + + def list(self) -> list[Checkpoint]: + paths = sorted((p for p in self.directory.glob("*.json") if not p.name.startswith(".")), + key=lambda p: (p.stat().st_mtime_ns, p.name)) + return [Checkpoint.from_json(p.read_text(encoding="utf-8")) for p in paths] + + def delete(self, checkpoint_id: str) -> None: + try: + self._path(checkpoint_id).unlink() + except FileNotFoundError: + pass + + +__all__ = ["Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint"] diff --git a/bridge-sdk/tests/test_checkpoint.py b/bridge-sdk/tests/test_checkpoint.py new file mode 100644 index 00000000..edce322d --- /dev/null +++ b/bridge-sdk/tests/test_checkpoint.py @@ -0,0 +1,219 @@ +"""Tests for the version-1 checkpoint allowlist and FileCheckpointStore (brief §2.10). + +``lifecycle.prepare`` does not exist yet (plan 4 task order runs checkpoint.py before +lifecycle.py, since EvmCall.send/SolCall.send depend on create_checkpoint) — plans are +built by hand from the real DEFAULT_REGISTRY instead of going through prepare(). +""" +import json +import os +import stat +import time +from decimal import Decimal + +import pytest + +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint +from aleo_bridge.errors import CheckpointInvalidError +from aleo_bridge.registry import DEFAULT_REGISTRY, Registry +from aleo_bridge.types import Plan, Receipt, Status + +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +APPROVAL = "0x" + "11" * 32 +SOURCE = "0x" + "22" * 32 +EVM1 = "0x0000000000000000000000000000000000000001" + + +def _make_plan(registry: Registry, *, source, destination, amount, recipient, + sender=None, protocol=None, mint_mode="public") -> Plan: + route = registry.find_route(source, destination, protocol=protocol) + src = registry.asset(source) + dst = registry.asset(destination) + amount_atomic = int(Decimal(amount) * (10 ** src.decimals)) + return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, + environment=route.environment, source_asset_id=src.id, destination_asset_id=dst.id, + amount=amount, amount_atomic=amount_atomic, recipient=recipient, sender=sender, + mint_mode=mint_mode, steps=()) + + +def _plan(**kw): + base = dict(source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=RECIPIENT, mint_mode="private") + base.update(kw) + return _make_plan(DEFAULT_REGISTRY, **base) + + +def test_allowlist_persists_intent_and_ids_only(): + plan = _plan() + receipt = Receipt(id="at1destination", protocol="xreserve", status=Status.DESTINATION_CONFIRMING, + source_tx_id=SOURCE, destination_tx_id="at1destination", + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "payload": "0xdeadbeef", "messageHash": "0x" + "33" * 32, + "nonce": "0x" + "44" * 32, "attestation": "0x" + "55" * 65, + "amountAtomic": "2000000", "maxFeeAtomic": "100000", + "remoteRecipientBytes32": "0x" + "66" * 32, + "secretNonce": "7scalar"}) + cp = create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + assert cp.to_dict() == { + "version": 1, + "receiptId": "at1destination", + "intent": {"source": {"chain": "sepolia", "asset": "usdc"}, + "destination": {"chain": "aleo-testnet", "asset": "usdcx"}, + "bridgeProtocol": "xreserve", "amount": "2", "recipient": RECIPIENT, + "mintMode": "private"}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"approvalTransactionIds": [APPROVAL], "transactionId": SOURCE}, + "destination": {"transactionId": "at1destination"}, + } + text = cp.to_json() + for forbidden in ("payload", "messageHash", "nonce", "attestation", "secretNonce", + "amountAtomic", "maxFeeAtomic", "remoteRecipientBytes32", "0xdeadbeef"): + assert forbidden not in text + assert cp.id == "at1destination" + assert Checkpoint.from_json(text) == cp + + +def test_mint_mode_only_when_destination_is_an_aleo_program(): + outbound = _make_plan(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=EVM1) + cp = create_checkpoint(outbound, Receipt(id="at1x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1x", protocol_state={"routeId": outbound.route_id}), + DEFAULT_REGISTRY) + assert "mintMode" not in cp.intent and cp.intent["bridgeProtocol"] == "hyperlane" + assert cp.source == {"transactionId": "at1x"} and cp.destination is None + assert "destination" not in cp.to_dict() + + +def test_sender_from_plan_or_source_sender(): + plan = _plan(sender=EVM1) + receipt = Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": "0x0000000000000000000000000000000000000002"}) + assert create_checkpoint(plan, receipt, DEFAULT_REGISTRY).intent["sender"] == EVM1 + plan2 = _plan() + assert create_checkpoint(plan2, receipt.replace(protocol_state={**receipt.protocol_state, "routeId": plan2.route_id}), + DEFAULT_REGISTRY).intent["sender"] == "0x0000000000000000000000000000000000000002" + assert create_checkpoint(plan2, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=SOURCE, protocol_state={"routeId": plan2.route_id}), + DEFAULT_REGISTRY).intent.get("sender") is None + + +def test_solana_blockhash_pair_both_or_neither(): + plan = _make_plan(DEFAULT_REGISTRY, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=RECIPIENT, sender="11111111111111111111111111111111") + ok = Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhash": "recent", "lastValidBlockHeight": "123456789"}) + assert create_checkpoint(plan, ok, DEFAULT_REGISTRY).source == { + "transactionId": "sig", "blockhash": "recent", "lastValidBlockHeight": "123456789"} + with pytest.raises(CheckpointInvalidError, match="blockhash"): + create_checkpoint(plan, ok.replace(protocol_state={"routeId": plan.route_id, "blockhash": "recent"}), + DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="blockhash"): + create_checkpoint(plan, ok.replace(protocol_state={"routeId": plan.route_id, "blockhash": "recent", + "lastValidBlockHeight": "abc"}), DEFAULT_REGISTRY) + + +def test_delivery_verification_pair_both_or_neither(): + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/sol", destination="solana/sol", amount="0.000000001", + recipient="11111111111111111111111111111111") + receipt = Receipt(id="at1s", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="at1s", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + cp = create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + assert cp.delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + with pytest.raises(CheckpointInvalidError, match="verification"): + create_checkpoint(plan, receipt.replace(protocol_state={"routeId": plan.route_id, + "destinationBalanceBeforeAtomic": "100"}), + DEFAULT_REGISTRY) + + +def test_prepared_transactions_and_hook_data(): + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM1) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + cp = create_checkpoint(plan, Receipt(id="at1prepared", protocol="hyperlane", + status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, + "preparedTransaction": serialized}), + DEFAULT_REGISTRY) + assert cp.source == {"preparedTransaction": {"transactionId": "at1prepared", + "serializedTransaction": serialized}} + with pytest.raises(CheckpointInvalidError, match="prepared transaction"): + create_checkpoint(plan, Receipt(id="x", protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": ""}), + DEFAULT_REGISTRY) + inbound = _plan() + cp2 = create_checkpoint(inbound, Receipt(id="at1mint", protocol="xreserve", + status=Status.DESTINATION_ACTION_REQUIRED, source_tx_id=SOURCE, + protocol_state={"routeId": inbound.route_id, "hookData": "0x02" + "00" * 64, + "preparedDestinationTransaction": serialized}), + DEFAULT_REGISTRY) + assert cp2.source == {"transactionId": SOURCE, "hookData": "0x02" + "00" * 64} + assert cp2.destination == {"preparedTransaction": {"transactionId": "at1mint", + "serializedTransaction": serialized}} + + +def test_rejects_receipts_from_other_routes_and_bad_approvals(): + plan = _plan() + with pytest.raises(CheckpointInvalidError, match="does not match"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": "xreserve:wrong/route"}), DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="does not match"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id}), DEFAULT_REGISTRY) + with pytest.raises(CheckpointInvalidError, match="approval"): + create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [1, 2]}), + DEFAULT_REGISTRY) + + +def test_from_dict_accepts_veil_shaped_checkpoints_without_receipt_id(): + veil = {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": "0.000000000000000001", "recipient": EVM1}, + "route": {"id": "hyperlane:aleo/eth->ethereum/eth", "registryVersion": DEFAULT_REGISTRY.version}, + "source": {"preparedTransaction": {"transactionId": "at1prepared", "serializedTransaction": "{}"}}} + cp = Checkpoint.from_dict(veil) + assert cp.id == "at1prepared" and cp.version == 1 and cp.delivery_verification is None + assert Checkpoint.from_dict({**veil, "source": {"transactionId": "at1src"}}).id == "at1src" + assert Checkpoint.from_dict({**veil, "source": {"approvalTransactionIds": [APPROVAL]}}).id == APPROVAL + with pytest.raises(CheckpointInvalidError): + Checkpoint.from_dict({"version": 2, "intent": {}, "route": {}}) + with pytest.raises(CheckpointInvalidError): + Checkpoint.from_dict({**veil, "source": {}}) + + +def test_file_store_roundtrip_mode_and_atomic_rename(tmp_path): + store = FileCheckpointStore(tmp_path / "cps") + plan = _plan() + cp = create_checkpoint(plan, Receipt(id=SOURCE, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=SOURCE, protocol_state={"routeId": plan.route_id}), + DEFAULT_REGISTRY) + store.save(cp) + files = list((tmp_path / "cps").iterdir()) + assert [f.name for f in files] == [f"{SOURCE}.json"] + assert stat.S_IMODE(os.stat(files[0]).st_mode) == 0o600 + assert not list((tmp_path / "cps").glob("*.tmp")) + assert store.load(SOURCE) == cp + assert store.load("missing") is None + assert store.list() == [cp] + store.delete(SOURCE) + store.delete(SOURCE) # idempotent + assert store.list() == [] and store.load(SOURCE) is None + + +def test_file_store_sanitizes_ids_and_orders_by_mtime(tmp_path): + store = FileCheckpointStore(str(tmp_path)) + plan = _make_plan(DEFAULT_REGISTRY, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM1) + a = create_checkpoint(plan, Receipt(id="at1a", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1a", protocol_state={"routeId": plan.route_id}), DEFAULT_REGISTRY) + weird = create_checkpoint(plan, Receipt(id="../evil id", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="../evil id", protocol_state={"routeId": plan.route_id}), + DEFAULT_REGISTRY) + store.save(a) + time.sleep(0.01) # keep mtimes ordered on coarse filesystems + store.save(weird) + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == ["___evil_id.json", "at1a.json"] + assert [c.id for c in store.list()] == ["at1a", "../evil id"] # oldest first + assert store.load("../evil id") == weird diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 1897356c..b47e0599 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -127,7 +127,7 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None Bridge.from_env(w3=marker) -def test_from_env_side_chain_variables(monkeypatch): +def test_from_env_side_chain_variables(monkeypatch, tmp_path): monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *a, **k: FakeAleo(mappings=default_mappings())) monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): @@ -144,9 +144,11 @@ def test_from_env_side_chain_variables(monkeypatch): with pytest.raises(MissingExtraError, match="solana"): # plan 3 makes this construct a Solana connection Bridge.from_env() monkeypatch.delenv("SOLANA_PRIVATE_KEY") - monkeypatch.setenv("BRIDGE_CHECKPOINT_DIR", "/tmp/cp") - with pytest.raises(ConfigurationError, match="plan 4"): # plan 4 wires FileCheckpointStore - Bridge.from_env() + monkeypatch.setenv("BRIDGE_CHECKPOINT_DIR", str(tmp_path / "cp")) + bridge = Bridge.from_env() # plan 4: FileCheckpointStore now wired + from aleo_bridge.checkpoint import FileCheckpointStore + assert isinstance(bridge.checkpoints, FileCheckpointStore) + assert bridge.checkpoints.directory == tmp_path / "cp" and bridge.checkpoints.directory.is_dir() def test_build_aleo_is_local_only(): @@ -172,7 +174,9 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None assert bridge.profile is not None and bridge.profile.home == tmp_path / "home" assert captured == {"endpoint": "https://api.provable.com/v2", "network": "testnet", "private_key": bridge.profile.private_key} assert bridge.environment == "testnet" and bridge.ethereum is None and bridge.solana is None - assert bridge.checkpoints is None # plan 4 binds FileCheckpointStore(profile.checkpoint_dir) + from aleo_bridge.checkpoint import FileCheckpointStore + assert isinstance(bridge.checkpoints, FileCheckpointStore) # plan 4 binds FileCheckpointStore(profile.checkpoint_dir) + assert bridge.checkpoints.directory == bridge.profile.checkpoint_dir assert bridge.profile.checkpoint_dir.is_dir() marker = object() assert Bridge.from_profile(ethereum=marker).ethereum is marker From 75052afec56f77310e38ebd3dc909bc273fb2eed Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:30:32 -0400 Subject: [PATCH 21/94] feat(bridge-sdk): Ethereum connection with three signer forms and fake 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). --- bridge-sdk/python/aleo_bridge/__init__.py | 4 +- bridge-sdk/python/aleo_bridge/_calls.py | 7 +- bridge-sdk/python/aleo_bridge/eth.py | 166 ++++++++++++++ bridge-sdk/tests/fakes/__init__.py | 0 bridge-sdk/tests/fakes/fake_web3.py | 225 +++++++++++++++++++ bridge-sdk/tests/test_client.py | 14 +- bridge-sdk/tests/test_eth_connection.py | 126 +++++++++++ bridge-sdk/tests/test_import_without_web3.py | 28 +++ 8 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/eth.py create mode 100644 bridge-sdk/tests/fakes/__init__.py create mode 100644 bridge-sdk/tests/fakes/fake_web3.py create mode 100644 bridge-sdk/tests/test_eth_connection.py create mode 100644 bridge-sdk/tests/test_import_without_web3.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index c5566adf..207be0c7 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -21,10 +21,11 @@ MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, to_progress, ) -from ._calls import AleoCall # noqa: E402 +from ._calls import AleoCall, EvmCall # noqa: E402 from .checkpoint import Checkpoint, CheckpointStore, FileCheckpointStore, create_checkpoint # noqa: E402 from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 +from .eth import Ethereum, EthModule # noqa: E402 from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from .privacy import PrivacyModule # noqa: E402 @@ -45,4 +46,5 @@ "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", + "EthModule", "Ethereum", "EvmCall", ] diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index d1735c99..e9c89b0e 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -209,4 +209,9 @@ def submit_prepared(self, prepared: PreparedTx, *, wait: bool = True, wait_timeo return self._build(prepared.transaction_id, root_outputs(decoded, self.program_id, self.function_name)) -__all__ = ["AleoCall", "extract_tx_id", "is_duplicate_submission", "output_values", "payload_transitions", "root_outputs"] +class EvmCall: + """Completed in Task 2.""" + + +__all__ = ["AleoCall", "EvmCall", "extract_tx_id", "is_duplicate_submission", "output_values", "payload_transitions", + "root_outputs"] diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py new file mode 100644 index 00000000..0925ccc3 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -0,0 +1,166 @@ +"""Ethereum connection and the ``bridge.eth`` module (Hyperlane + xReserve, Ethereum origin). + +``web3`` and ``eth_account`` are imported lazily so ``import aleo_bridge`` works +without the ``evm`` extra; the first call that needs them raises +``MissingExtraError("evm", ...)``. +""" +from __future__ import annotations + +import os +from typing import Any, Mapping + +from .errors import ConfigurationError, MissingExtraError + + +def _web3(): + try: + import web3 + except ImportError as exc: # pragma: no cover - exercised by test_import_without_web3 + raise MissingExtraError("evm", "Ethereum connections") from exc + return web3 + + +def _eth_account(): + try: + from eth_account import Account + except ImportError as exc: # pragma: no cover + raise MissingExtraError("evm", "Ethereum signing") from exc + return Account + + +class Ethereum: + """Transport + optional signer for Ethereum-origin bridge actions. + + Three interchangeable forms:: + + Ethereum(rpc_url, private_key=key) # SDK builds Web3(HTTPProvider(rpc_url)) + Ethereum(w3=my_w3, signer=local_account) # caller's Web3, caller's eth_account signer + Ethereum(w3=my_w3) # signs via w3.eth.default_account + caller middleware, + # else read-only + + Sending: with a ``LocalAccount`` the SDK fills nonce/gas/fee fields, signs, and + ``send_raw_transaction``s; in default-account mode it calls + ``w3.eth.send_transaction`` so the caller's middleware signs. Receipts are + polled on the same ``Web3``. + """ + + def __init__(self, rpc_url: str | None = None, *, w3: Any = None, signer: Any = None, + private_key: str | None = None) -> None: + if (rpc_url is None) == (w3 is None): + raise ConfigurationError("Pass exactly one of rpc_url or w3 to Ethereum(...)") + if signer is not None and private_key is not None: + raise ConfigurationError("Pass at most one of signer or private_key to Ethereum(...)") + if w3 is None: + web3 = _web3() + w3 = web3.Web3(web3.HTTPProvider(rpc_url)) + if private_key is not None: + signer = _eth_account().from_key(private_key) + self._w3 = w3 + self._signer = signer + self._chain_id: int | None = None + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "Ethereum | None": + """``EVM_PRIVATE_KEY`` + ``ETHEREUM_RPC_URL`` (both or neither) → signing connection; neither → None.""" + env = os.environ if env is None else env + key = env.get("EVM_PRIVATE_KEY") + url = env.get("ETHEREUM_RPC_URL") + if bool(key) != bool(url): + raise ConfigurationError("Set both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL or neither") + if not key: + return None + return cls(url, private_key=key) + + @property + def w3(self) -> Any: + return self._w3 + + @property + def address(self) -> str | None: + """Checksummed sender address: signer → ``w3.eth.default_account`` → ``None``.""" + if self._signer is not None: + return self._signer.address + default = getattr(self._w3.eth, "default_account", None) + if isinstance(default, str) and default: + return _web3().Web3.to_checksum_address(default) + return None + + @property + def can_sign(self) -> bool: + return self.address is not None + + @property + def chain_id(self) -> int: + """``eth_chainId``, read once and cached.""" + if self._chain_id is None: + self._chain_id = int(self._w3.eth.chain_id) + return self._chain_id + + def require_address(self) -> str: + address = self.address + if address is None: + raise ConfigurationError( + "This Ethereum connection is read-only: pass private_key= or signer= to Ethereum(...), " + "or set w3.eth.default_account with signing middleware") + return address + + def send_transaction(self, tx: dict) -> str: + """Broadcast one transaction and return its ``0x`` hash. + + Fills ``from``/``chainId``/``value`` when missing. Local signer: also fills + ``nonce``/``gas``/fee fields, signs, ``send_raw_transaction``. Default-account + mode: ``send_transaction`` (the caller's middleware signs and fills gas). + Read-only: ``ConfigurationError``. + """ + sender = self.require_address() + Web3 = _web3().Web3 + tx = dict(tx) + tx.setdefault("from", sender) + if Web3.to_checksum_address(tx["from"]) != sender: + raise ConfigurationError(f"Transaction sender {tx['from']} does not match the configured account {sender}") + tx.setdefault("chainId", self.chain_id) + tx.setdefault("value", 0) + if self._signer is None: + return Web3.to_hex(self._w3.eth.send_transaction(tx)) + tx.setdefault("nonce", self._w3.eth.get_transaction_count(sender, "pending")) + if "gas" not in tx: + estimate_fields = {k: v for k, v in tx.items() if k in ("from", "to", "data", "value")} + tx["gas"] = int(self._w3.eth.estimate_gas(estimate_fields)) * 12 // 10 + if "gasPrice" not in tx and "maxFeePerGas" not in tx: + base_fee = self._w3.eth.get_block("latest").get("baseFeePerGas") + if base_fee is None: + tx["gasPrice"] = int(self._w3.eth.gas_price) + else: + tip = int(self._w3.eth.max_priority_fee) + tx["maxPriorityFeePerGas"] = tip + tx["maxFeePerGas"] = int(base_fee) * 2 + tip + signed = self._signer.sign_transaction(tx) + return Web3.to_hex(self._w3.eth.send_raw_transaction(signed.raw_transaction)) + + def wait_for_receipt(self, tx_hash: str, *, timeout_seconds: float, poll_seconds: float) -> dict | None: + """Poll ``wait_for_transaction_receipt``; ``None`` on timeout (a timeout is not a failure).""" + from web3.exceptions import TimeExhausted + + try: + return self._w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout_seconds, poll_latency=poll_seconds) + except TimeExhausted: + return None + + def get_receipt(self, tx_hash: str) -> dict | None: + """One ``eth_getTransactionReceipt`` read; ``None`` while the transaction is unmined or unknown.""" + from web3.exceptions import TransactionNotFound + + try: + return self._w3.eth.get_transaction_receipt(tx_hash) + except TransactionNotFound: + return None + + +class EthModule: + """Completed in Task 2.""" + + def __init__(self, bridge: Any, conn: Ethereum) -> None: + self._bridge, self._conn = bridge, conn + + +__all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/fakes/__init__.py b/bridge-sdk/tests/fakes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py new file mode 100644 index 00000000..290f5f63 --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -0,0 +1,225 @@ +"""A hand-rolled JSON-RPC provider behind a real ``web3.Web3``. + +The real web3 contract/ABI/event/signing stack runs unchanged; only the +transport is fake, so tests exercise the exact calldata, event decoding and +raw-transaction signing that production uses. State is plain dicts the test +mutates directly. + +The Aleo-side fake facade lives in ``tests/conftest.py`` (``FakeAleo``, +``bridge``/``fake_aleo`` fixtures) — this module holds only web3-specific +fakes so there is exactly one Aleo fake in the suite. +""" +from __future__ import annotations + +from typing import Any, Callable + +from eth_abi import decode, encode +from eth_account import Account +from eth_utils import keccak, to_checksum_address +from hexbytes import HexBytes +from web3 import Web3 +from web3.providers import BaseProvider + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +BLOCK_HASH = "0x" + "cd" * 32 +SELECTORS = { + keccak(text="balanceOf(address)")[:4]: "balanceOf", + keccak(text="allowance(address,address)")[:4]: "allowance", + keccak(text="quoteTransferRemote(uint32,bytes32,uint256)")[:4]: "quoteTransferRemote", + keccak(text="delivered(bytes32)")[:4]: "delivered", +} +TOPIC_DISPATCH_ID = "0x" + keccak(text="DispatchId(bytes32)").hex() +TOPIC_SENT_TRANSFER_REMOTE = "0x" + keccak(text="SentTransferRemote(uint32,bytes32,uint256)").hex() +TOPIC_DEPOSITED_TO_REMOTE = "0x" + keccak( + text="DepositedToRemote(address,uint256,address,bytes32,uint32,bytes32,uint256,bytes)" +).hex() + + +def tx_hash_for(n: int) -> str: + """Deterministic hash of the n-th (1-based) transaction the fake accepted.""" + return "0x" + keccak(text=f"fake-tx-{n}").hex() + + +def _hex(n: int) -> str: + return hex(n) + + +def event_log(address: str, topics: list[str], data: str, *, log_index: int, tx_hash: str, + block_number: int = 0x11) -> dict: + return {"address": to_checksum_address(address), "topics": topics, "data": data, + "logIndex": _hex(log_index), "blockNumber": _hex(block_number), "transactionHash": tx_hash, + "transactionIndex": "0x0", "blockHash": BLOCK_HASH, "removed": False} + + +def dispatch_id_log(mailbox: str, message_id: bytes, *, tx_hash: str, log_index: int = 5, block_number: int = 0x11) -> dict: + return event_log(mailbox, [TOPIC_DISPATCH_ID, "0x" + message_id.hex()], "0x", + log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def sent_transfer_remote_log(router: str, *, destination: int, recipient32: bytes, amount: int, tx_hash: str, + log_index: int = 4, block_number: int = 0x65) -> dict: + topics = [TOPIC_SENT_TRANSFER_REMOTE, "0x" + encode(["uint32"], [destination]).hex(), "0x" + recipient32.hex()] + return event_log(router, topics, "0x" + encode(["uint256"], [amount]).hex(), + log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def deposited_log(xreserve: str, *, local_token: str, depositor: str, remote_recipient32: bytes, value: int, + remote_domain: int, remote_token32: bytes, max_fee: int, hook_data: bytes, tx_hash: str, + log_index: int = 3, block_number: int = 0x65) -> dict: + topics = [TOPIC_DEPOSITED_TO_REMOTE, "0x" + encode(["address"], [local_token]).hex(), + "0x" + encode(["address"], [depositor]).hex(), "0x" + remote_recipient32.hex()] + data = encode(["uint256", "uint32", "bytes32", "uint256", "bytes"], + [value, remote_domain, remote_token32, max_fee, hook_data]) + return event_log(xreserve, topics, "0x" + data.hex(), log_index=log_index, tx_hash=tx_hash, block_number=block_number) + + +def _decode_raw(raw: bytes) -> dict: + """Signed raw tx → {to, value, data, from}. Typed (0x02) and legacy envelopes.""" + from eth_account.typed_transactions import TypedTransaction + + sender = Account.recover_transaction(raw) + if raw[0] <= 0x7F: + fields = TypedTransaction.from_bytes(HexBytes(raw)).as_dict() + else: + import rlp + from eth_account._utils.legacy_transactions import Transaction + + fields = rlp.decode(raw, Transaction).as_dict() + data = fields.get("data", b"") + data_hex = data if isinstance(data, str) else "0x" + bytes(data).hex() + return {"to": to_checksum_address(fields["to"]), "value": int(fields.get("value", 0)), "data": data_hex, "from": sender} + + +class FakeRpcProvider(BaseProvider): + """State: balances, allowances, router quotes, delivered ids, sent txs, receipts, history logs.""" + + def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = None, + token_balances: dict[tuple[str, str], int] | None = None, + allowances: dict[tuple[str, str, str], int] | None = None, + quotes: dict[str, list[tuple[str, int]]] | None = None, + delivered: set[str] | None = None) -> None: + super().__init__() + self.chain_id = chain_id + self.eth_balances = {to_checksum_address(k): v for k, v in (eth_balances or {}).items()} + self.token_balances = {(to_checksum_address(t), to_checksum_address(o)): v + for (t, o), v in (token_balances or {}).items()} + self.allowances = {(to_checksum_address(t), to_checksum_address(o), to_checksum_address(s)): v + for (t, o, s), v in (allowances or {}).items()} + self.quotes = {to_checksum_address(r): q for r, q in (quotes or {}).items()} + self.delivered = {d.lower() for d in (delivered or set())} + self.sent: list[dict] = [] # {to, value, data, from, hash} in send order + self.pending: set[str] = set() # hashes whose receipt stays None + self.reverted: set[str] = set() # hashes whose receipt has status 0 + self.receipt_logs: Callable[[dict], list[dict]] = lambda tx: [] # logs for a sent tx's receipt + self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/fromBlock) + self.transactions: dict[str, dict] = {} # extra eth_getTransactionByHash answers + self.receipts: dict[str, dict] = {} # extra eth_getTransactionReceipt answers + self.block_number = 0x10 + self.methods: list[str] = [] + + def _ok(self, result: Any) -> dict: + return {"jsonrpc": "2.0", "id": 1, "result": result} + + def make_request(self, method: str, params: Any) -> dict: + self.methods.append(method) + if method == "eth_chainId": + return self._ok(_hex(self.chain_id)) + if method == "eth_blockNumber": + return self._ok(_hex(self.block_number)) + if method == "eth_gasPrice": + return self._ok(_hex(10**9)) + if method == "eth_maxPriorityFeePerGas": + return self._ok(_hex(10**8)) + if method == "eth_feeHistory": + return self._ok({"baseFeePerGas": [_hex(10**9)] * 2, "gasUsedRatio": [0.5], + "oldestBlock": "0x1", "reward": [[_hex(10**8)]]}) + if method == "eth_getBlockByNumber": + return self._ok({"number": _hex(self.block_number), "baseFeePerGas": _hex(10**9), "gasLimit": _hex(30_000_000), + "gasUsed": "0x0", "timestamp": "0x0", "hash": "0x" + "ab" * 32, "parentHash": "0x" + "00" * 32, + "transactions": [], "difficulty": "0x0", "extraData": "0x", "logsBloom": "0x" + "00" * 256, + "miner": ZERO_ADDRESS, "mixHash": "0x" + "00" * 32, "nonce": "0x0000000000000000", + "receiptsRoot": "0x" + "00" * 32, "sha3Uncles": "0x" + "00" * 32, "size": "0x1", + "stateRoot": "0x" + "00" * 32, "totalDifficulty": "0x0", + "transactionsRoot": "0x" + "00" * 32, "uncles": []}) + if method == "eth_getTransactionCount": + return self._ok(_hex(len(self.sent))) + if method == "eth_estimateGas": + return self._ok(_hex(150_000)) + if method == "eth_getBalance": + return self._ok(_hex(self.eth_balances.get(to_checksum_address(params[0]), 0))) + if method == "eth_call": + return self._ok(self._call(params[0])) + if method == "eth_sendRawTransaction": + return self._ok(self._accept(_decode_raw(bytes.fromhex(params[0][2:])))) + if method == "eth_sendTransaction": + p = params[0] + raw_value = p.get("value", 0) + value = int(raw_value, 16) if isinstance(raw_value, str) else int(raw_value) + return self._ok(self._accept({"to": to_checksum_address(p["to"]), "value": value, + "data": p.get("data", "0x"), "from": to_checksum_address(p["from"])})) + if method == "eth_getTransactionReceipt": + return self._ok(self._receipt(params[0])) + if method == "eth_getLogs": + f = params[0] + addr = f.get("address") + addrs = {to_checksum_address(a) for a in (addr if isinstance(addr, list) else [addr])} if addr else None + raw_from = f.get("fromBlock", 0) + from_block = int(raw_from, 16) if isinstance(raw_from, str) else int(raw_from) + return self._ok([log for log in self.history_logs + if (addrs is None or log["address"] in addrs) and int(log["blockNumber"], 16) >= from_block]) + if method == "eth_getTransactionByHash": + h = params[0] + if h in self.transactions: + return self._ok(self.transactions[h]) + sent = next((t for t in self.sent if t["hash"] == h), None) + if sent is None: + return self._ok(None) + return self._ok({"hash": h, "from": sent["from"], "to": sent["to"], "input": sent["data"], + "value": _hex(sent["value"]), "blockNumber": _hex(self.block_number + 1), + "blockHash": BLOCK_HASH, "nonce": "0x0", "gas": "0x1", "gasPrice": "0x1", + "transactionIndex": "0x0", "type": "0x2", "chainId": _hex(self.chain_id), + "v": "0x0", "r": "0x0", "s": "0x0"}) + raise NotImplementedError(method) + + def _accept(self, tx: dict) -> str: + self.sent.append(tx) + tx["hash"] = tx_hash_for(len(self.sent)) + return tx["hash"] + + def _receipt(self, h: str) -> dict | None: + if h in self.pending: + return None + if h in self.receipts: + return self.receipts[h] + sent = next((t for t in self.sent if t["hash"] == h), None) + if sent is None: + return None + return {"transactionHash": h, "status": "0x0" if h in self.reverted else "0x1", + "blockNumber": _hex(self.block_number + 1), "blockHash": BLOCK_HASH, "transactionIndex": "0x0", + "from": sent["from"], "to": sent["to"], "cumulativeGasUsed": "0x1", "gasUsed": "0x1", + "effectiveGasPrice": "0x1", "type": "0x2", "contractAddress": None, + "logsBloom": "0x" + "00" * 256, "logs": self.receipt_logs(sent)} + + def _call(self, call: dict) -> str: + to = to_checksum_address(call["to"]) + data = bytes.fromhex(call["data"][2:]) + name = SELECTORS.get(data[:4]) + args = data[4:] + if name == "balanceOf": + (owner,) = decode(["address"], args) + return "0x" + encode(["uint256"], [self.token_balances.get((to, to_checksum_address(owner)), 0)]).hex() + if name == "allowance": + owner, spender = decode(["address", "address"], args) + key = (to, to_checksum_address(owner), to_checksum_address(spender)) + return "0x" + encode(["uint256"], [self.allowances.get(key, 0)]).hex() + if name == "quoteTransferRemote": + return "0x" + encode(["(address,uint256)[]"], [self.quotes.get(to, [])]).hex() + if name == "delivered": + (message_id,) = decode(["bytes32"], args) + return "0x" + encode(["bool"], [("0x" + message_id.hex()) in self.delivered]).hex() + raise NotImplementedError(f"eth_call selector {data[:4].hex()} to {to}") + + +def fake_web3(**config: Any) -> Web3: + """A real ``Web3`` over ``FakeRpcProvider``; reach the state through ``w3.provider``.""" + return Web3(FakeRpcProvider(**config)) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index b47e0599..5d4185f7 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -1,12 +1,14 @@ import json import pytest +from eth_account import Account as EthAccount from aleo.facade.errors import ProgramNotFound from aleo_bridge import Bridge, __main__ as cli from aleo_bridge._calls import AleoCall from aleo_bridge.errors import ConfigurationError, MissingExtraError +from aleo_bridge.eth import Ethereum, EthModule from aleo_bridge.freezelist import FreezeList from aleo_bridge.hyperlane import HyperlaneModule from aleo_bridge.privacy import PrivacyModule @@ -14,6 +16,7 @@ from aleo_bridge.types import BridgeStatus from aleo_bridge.xreserve import XReserveModule from tests.conftest import SIGNER, FakeAleo, default_mappings +from tests.fakes.fake_web3 import fake_web3 def test_construction_defaults_and_namespaces(fake_aleo): @@ -39,14 +42,14 @@ def test_construction_errors(fake_aleo): Bridge(FakeAleo(default_account=False)).aleo_address() -def test_eth_and_sol_properties_before_plans_2_and_3(fake_aleo): +def test_eth_property_wraps_connection_and_sol_property_before_plan_3(fake_aleo): bridge = Bridge(fake_aleo) with pytest.raises(ConfigurationError, match="ethereum="): bridge.eth with pytest.raises(ConfigurationError, match="solana="): bridge.sol - with pytest.raises(MissingExtraError, match="aleo-bridge-sdk\\[evm\\]"): # plan 2 replaces: real Ethereum wraps a bare Web3 - Bridge(fake_aleo, ethereum=object()).eth + eth_module = Bridge(fake_aleo, ethereum=fake_web3()).eth # plan 2: real Ethereum wraps a bare Web3 + assert isinstance(eth_module, EthModule) with pytest.raises(MissingExtraError, match="aleo-bridge-sdk\\[solana\\]"): Bridge(fake_aleo, solana=object()).sol @@ -136,8 +139,9 @@ def test_from_env_side_chain_variables(monkeypatch, tmp_path): with pytest.raises(ConfigurationError, match="both or neither"): Bridge.from_env() monkeypatch.setenv("ETHEREUM_RPC_URL", "https://eth.example") - with pytest.raises(MissingExtraError, match="evm"): # plan 2 makes this construct an Ethereum connection - Bridge.from_env() + bridge = Bridge.from_env() # plan 2: real Ethereum connection now constructed + assert isinstance(bridge.ethereum, Ethereum) + assert bridge.ethereum.address == EthAccount.from_key("0x" + "11" * 32).address monkeypatch.delenv("EVM_PRIVATE_KEY") monkeypatch.delenv("ETHEREUM_RPC_URL") monkeypatch.setenv("SOLANA_PRIVATE_KEY", "5" * 88) diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py new file mode 100644 index 00000000..d64e0c59 --- /dev/null +++ b/bridge-sdk/tests/test_eth_connection.py @@ -0,0 +1,126 @@ +import pytest +from eth_account import Account +from web3 import Web3 +from web3.middleware import SignAndSendRawMiddlewareBuilder + +from aleo_bridge.errors import ConfigurationError +from tests.fakes.fake_web3 import fake_web3, tx_hash_for + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +TO = "0x0000000000000000000000000000000000000002" + + +def test_exactly_one_transport(): + from aleo_bridge.eth import Ethereum + + with pytest.raises(ConfigurationError, match="exactly one of rpc_url or w3"): + Ethereum() + with pytest.raises(ConfigurationError, match="exactly one of rpc_url or w3"): + Ethereum("http://localhost:8545", w3=fake_web3()) + + +def test_at_most_one_signer(): + from aleo_bridge.eth import Ethereum + + with pytest.raises(ConfigurationError, match="at most one of signer or private_key"): + Ethereum(w3=fake_web3(), signer=ACCT, private_key=KEY) + + +def test_rpc_url_builds_http_provider_lazily(): + from web3 import HTTPProvider + + from aleo_bridge.eth import Ethereum + + conn = Ethereum("http://127.0.0.1:1", private_key=KEY) + assert isinstance(conn.w3.provider, HTTPProvider) + assert conn.address == ACCT.address and conn.can_sign + + +def test_w3_plus_signer_form_and_cached_chain_id(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3(chain_id=11155111) + conn = Ethereum(w3=w3, signer=ACCT) + assert conn.w3 is w3 and conn.address == ACCT.address and conn.can_sign + assert conn.chain_id == 11155111 + assert conn.chain_id == 11155111 and w3.provider.methods.count("eth_chainId") == 1 + + +def test_w3_alone_is_read_only_without_default_account(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3()) + assert conn.address is None and not conn.can_sign + with pytest.raises(ConfigurationError, match="read-only"): + conn.send_transaction({"to": TO, "value": 1, "data": "0x"}) + with pytest.raises(ConfigurationError, match="read-only"): + conn.require_address() + + +def test_w3_default_account_uses_callers_middleware(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + w3.middleware_onion.inject(SignAndSendRawMiddlewareBuilder.build(ACCT), layer=0) + w3.eth.default_account = ACCT.address + conn = Ethereum(w3=w3) + assert conn.address == ACCT.address and conn.can_sign + h = conn.send_transaction({"to": TO, "value": 1, "data": "0x"}) + assert h == tx_hash_for(1) + assert "eth_sendRawTransaction" in w3.provider.methods # the caller's middleware signed + assert w3.provider.sent[0]["from"] == ACCT.address and w3.provider.sent[0]["value"] == 1 + + +def test_local_account_path_signs_and_sends_raw(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 7, "data": "0x"}) + assert h == tx_hash_for(1) + assert "eth_sendRawTransaction" in w3.provider.methods and "eth_sendTransaction" not in w3.provider.methods + sent = w3.provider.sent[0] + assert sent["from"] == ACCT.address and sent["to"] == Web3.to_checksum_address(TO) and sent["value"] == 7 + + +def test_sender_mismatch_is_refused(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3(), private_key=KEY) + with pytest.raises(ConfigurationError, match="does not match the configured account"): + conn.send_transaction({"from": TO, "to": TO, "value": 0, "data": "0x"}) + + +def test_wait_for_receipt_returns_none_on_timeout_and_dict_on_success(): + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 0, "data": "0x"}) + w3.provider.pending.add(h) + assert conn.wait_for_receipt(h, timeout_seconds=0.01, poll_seconds=0.001) is None + assert conn.get_receipt(h) is None + w3.provider.pending.clear() + receipt = conn.wait_for_receipt(h, timeout_seconds=1.0, poll_seconds=0.001) + assert receipt is not None and int(receipt["status"]) == 1 and int(receipt["blockNumber"]) == 0x11 + assert Web3.to_hex(conn.get_receipt(h)["transactionHash"]) == h + + +def test_get_receipt_missing_hash_is_none(): + from aleo_bridge.eth import Ethereum + + conn = Ethereum(w3=fake_web3()) + assert conn.get_receipt("0x" + "99" * 32) is None + + +def test_from_env(): + from aleo_bridge.eth import Ethereum + + assert Ethereum.from_env({}) is None + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env({"EVM_PRIVATE_KEY": KEY}) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env({"ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + conn = Ethereum.from_env({"EVM_PRIVATE_KEY": KEY, "ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) + assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" diff --git a/bridge-sdk/tests/test_import_without_web3.py b/bridge-sdk/tests/test_import_without_web3.py new file mode 100644 index 00000000..edd35505 --- /dev/null +++ b/bridge-sdk/tests/test_import_without_web3.py @@ -0,0 +1,28 @@ +"""``import aleo_bridge`` must succeed with web3/eth_account absent; only the first Ethereum(...) +call that actually needs them raises MissingExtraError. Uses the same monkeypatch-and-revert +pattern as test_package.py's test_import_without_optional_extras so the blocked modules and the +reimported aleo_bridge never leak into tests that run after this one. +""" +from __future__ import annotations + +import importlib +import sys + +import pytest + + +def test_package_imports_without_web3(monkeypatch): + monkeypatch.setitem(sys.modules, "web3", None) + monkeypatch.setitem(sys.modules, "eth_account", None) + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + monkeypatch.delitem(sys.modules, name) + + pkg = importlib.import_module("aleo_bridge") + assert pkg.Ethereum is not None and pkg.EthModule is not None and pkg.EvmCall is not None + + from aleo_bridge.errors import MissingExtraError + + with pytest.raises(MissingExtraError) as exc_info: + pkg.Ethereum("http://127.0.0.1:1") + assert "aleo-bridge-sdk[evm]" in str(exc_info.value) From 41ecd18aa72146eb18e3eb06e0f71c6734057ea7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:35:16 -0400 Subject: [PATCH 22/94] feat(bridge-sdk): EvmCall step runner with pre-poll checkpoints, EVM ABI fragments, _plan_for --- bridge-sdk/python/aleo_bridge/_calls.py | 117 ++++++++++++++++- bridge-sdk/python/aleo_bridge/_evm_abi.py | 60 +++++++++ bridge-sdk/python/aleo_bridge/eth.py | 45 ++++++- bridge-sdk/tests/test_evm_call.py | 150 ++++++++++++++++++++++ 4 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/_evm_abi.py create mode 100644 bridge-sdk/tests/test_evm_call.py diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index e9c89b0e..c8af4d87 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -9,9 +9,10 @@ from __future__ import annotations import json +from dataclasses import dataclass from typing import Any, Callable, Generic, TypeVar -from .errors import ConfigurationError +from .errors import BridgeError, ConfigurationError from .types import PreparedTx R = TypeVar("R") @@ -209,9 +210,115 @@ def submit_prepared(self, prepared: PreparedTx, *, wait: bool = True, wait_timeo return self._build(prepared.transaction_id, root_outputs(decoded, self.program_id, self.function_name)) -class EvmCall: - """Completed in Task 2.""" +@dataclass(frozen=True) +class EvmStep: + """One unsigned EVM transaction the call will broadcast, in order.""" + kind: str # "approve" | "main" + to: str + data: str # 0x calldata + value: int = 0 # wei (msg.value) -__all__ = ["AleoCall", "EvmCall", "extract_tx_id", "is_duplicate_submission", "output_values", "payload_transitions", - "root_outputs"] + +@dataclass(frozen=True) +class EvmOutcome: + """What the step runner observed; the module's ``finish`` turns it into the typed result.""" + + status: str # "SOURCE_APPROVAL_PENDING" | "SOURCE_CONFIRMING" | "CONFIRMED" + sender: str + approval_tx_ids: tuple[str, ...] + source_tx_id: str | None + receipt: Any | None # web3 receipt when status == "CONFIRMED" + + +def _assert_evm_success(receipt: Any, tx_hash: str) -> None: + if int(receipt["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {tx_hash}") + + +class EvmCall(Generic[R]): + """A prepared Ethereum write: ``build()`` for unsigned transaction dicts, ``send()`` to broadcast. + + ``steps(sender)`` is evaluated at ``build``/``send`` time so allowances and router + fees are read at the last responsible moment. ``send`` broadcasts approvals then the + main call, emits a ``Checkpoint`` after every broadcast (before polling) and after + confirmation, and returns a pending result when a receipt does not arrive within + ``timeout_seconds`` — a timeout is not a failure. + """ + + def __init__(self, conn: Any, *, plan: "Plan", registry: "Registry", + steps: Callable[[str], list[EvmStep]], finish: Callable[[EvmOutcome], R], + store: "CheckpointStore | None" = None) -> None: + self._conn, self.plan, self._registry = conn, plan, registry + self._steps, self._finish, self._store = steps, finish, store + + def _sender(self) -> str: + from .errors import ConfigurationError + + sender = self._conn.require_address() + if self.plan.sender: + Web3 = self._conn.w3.__class__ + if Web3.to_checksum_address(self.plan.sender) != sender: + raise ConfigurationError( + f"Prepared sender {self.plan.sender} does not match connected account {sender}") + return sender + + def build(self) -> list[dict]: + """Unsigned transaction dicts in submission order (approvals then main). Reads only.""" + from .errors import ConfigurationError + + sender = self._conn.address or self.plan.sender + if sender is None: + raise ConfigurationError("build() needs a sender: configure a signer or set plan.sender") + nonce = int(self._conn.w3.eth.get_transaction_count(sender, "pending")) + return [{"from": sender, "to": step.to, "data": step.data, "value": step.value, + "chainId": self._conn.chain_id, "nonce": nonce + i} + for i, step in enumerate(self._steps(sender))] + + def _checkpoint(self, result: R, on_checkpoint: Callable[["Checkpoint"], None] | None) -> None: + from .checkpoint import create_checkpoint + + checkpoint = create_checkpoint(self.plan, result.receipt, self._registry) # type: ignore[attr-defined] + if self._store is not None: + self._store.save(checkpoint) + if on_checkpoint is not None: + on_checkpoint(checkpoint) + + def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, + on_checkpoint: Callable[["Checkpoint"], None] | None = None) -> R: + """Broadcast every step in order; checkpoint each hash before polling; pending on timeout. + + ``wait=False`` broadcasts only the first step and returns its pending result; call + ``bridge.eth.source_status`` (or plan 4's ``resume``) to continue. + """ + sender = self._sender() + approvals: list[str] = [] + for step in self._steps(sender): + tx_hash = self._conn.send_transaction({"from": sender, "to": step.to, "data": step.data, "value": step.value}) + if step.kind == "approve": + approvals.append(tx_hash) + pending = self._finish(EvmOutcome("SOURCE_APPROVAL_PENDING", sender, tuple(approvals), None, None)) + self._checkpoint(pending, on_checkpoint) + if not wait: + return pending + receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + if receipt is None: + return pending + _assert_evm_success(receipt, tx_hash) + continue + pending = self._finish(EvmOutcome("SOURCE_CONFIRMING", sender, tuple(approvals), tx_hash, None)) + self._checkpoint(pending, on_checkpoint) + if not wait: + return pending + receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + if receipt is None: + return pending + _assert_evm_success(receipt, tx_hash) + confirmed = self._finish(EvmOutcome("CONFIRMED", sender, tuple(approvals), tx_hash, receipt)) + self._checkpoint(confirmed, on_checkpoint) + return confirmed + raise BridgeError("EvmCall has no main step") + + +__all__ = ["AleoCall", "EvmCall", "EvmOutcome", "EvmStep", "extract_tx_id", "is_duplicate_submission", + "output_values", "payload_transitions", "root_outputs"] diff --git a/bridge-sdk/python/aleo_bridge/_evm_abi.py b/bridge-sdk/python/aleo_bridge/_evm_abi.py new file mode 100644 index 00000000..5fdb7fc5 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_evm_abi.py @@ -0,0 +1,60 @@ +"""Minimal ABI fragments for the reviewed Ethereum deployments (brief §3.1, §3.2, §3.7). Pure data.""" +from __future__ import annotations + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +# The single EVM chain the bridge drives per environment (base/hyperevm carry only metadata-required routes). +EVM_CHAIN_BY_ENVIRONMENT = {"mainnet": "ethereum", "testnet": "sepolia"} + +ERC20_ABI = [ + {"type": "function", "name": "balanceOf", "stateMutability": "view", + "inputs": [{"name": "owner", "type": "address"}], "outputs": [{"name": "", "type": "uint256"}]}, + {"type": "function", "name": "allowance", "stateMutability": "view", + "inputs": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}]}, + {"type": "function", "name": "approve", "stateMutability": "nonpayable", + "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "", "type": "bool"}]}, +] + +WARP_ROUTE_ABI = [ + {"type": "function", "name": "quoteTransferRemote", "stateMutability": "view", + "inputs": [{"name": "destination", "type": "uint32"}, {"name": "recipient", "type": "bytes32"}, + {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "quotes", "type": "tuple[]", + "components": [{"name": "token", "type": "address"}, {"name": "amount", "type": "uint256"}]}]}, + {"type": "function", "name": "transferRemote", "stateMutability": "payable", + "inputs": [{"name": "destination", "type": "uint32"}, {"name": "recipient", "type": "bytes32"}, + {"name": "amount", "type": "uint256"}], + "outputs": [{"name": "messageId", "type": "bytes32"}]}, + {"type": "event", "name": "SentTransferRemote", "anonymous": False, + "inputs": [{"name": "destination", "type": "uint32", "indexed": True}, + {"name": "recipient", "type": "bytes32", "indexed": True}, + {"name": "amount", "type": "uint256", "indexed": False}]}, +] + +XRESERVE_ABI = [ + {"type": "function", "name": "depositToRemote", "stateMutability": "nonpayable", + "inputs": [{"name": "value", "type": "uint256"}, {"name": "remoteDomain", "type": "uint32"}, + {"name": "remoteRecipient", "type": "bytes32"}, {"name": "localToken", "type": "address"}, + {"name": "maxFee", "type": "uint256"}, {"name": "hookData", "type": "bytes"}], + "outputs": []}, + {"type": "event", "name": "DepositedToRemote", "anonymous": False, + "inputs": [{"name": "localToken", "type": "address", "indexed": True}, + {"name": "value", "type": "uint256", "indexed": False}, + {"name": "localDepositor", "type": "address", "indexed": True}, + {"name": "remoteRecipient", "type": "bytes32", "indexed": True}, + {"name": "remoteDomain", "type": "uint32", "indexed": False}, + {"name": "remoteToken", "type": "bytes32", "indexed": False}, + {"name": "maxFee", "type": "uint256", "indexed": False}, + {"name": "hookData", "type": "bytes", "indexed": False}]}, +] + +MAILBOX_ABI = [ + {"type": "event", "name": "DispatchId", "anonymous": False, + "inputs": [{"name": "messageId", "type": "bytes32", "indexed": True}]}, + {"type": "function", "name": "delivered", "stateMutability": "view", + "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"name": "", "type": "bool"}]}, +] + +__all__ = ["ERC20_ABI", "EVM_CHAIN_BY_ENVIRONMENT", "MAILBOX_ABI", "WARP_ROUTE_ABI", "XRESERVE_ABI", "ZERO_ADDRESS"] diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 0925ccc3..84fec33e 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -9,7 +9,11 @@ import os from typing import Any, Mapping -from .errors import ConfigurationError, MissingExtraError +from ._evm_abi import EVM_CHAIN_BY_ENVIRONMENT +from .errors import BridgeError, ConfigurationError, MissingExtraError +from .registry import Asset, Chain, Registry, Route +from .types import Plan, Step +from .units import format_decimal_amount def _web3(): @@ -156,11 +160,46 @@ def get_receipt(self, tx_hash: str) -> dict | None: return None +def _plan_for(registry: Registry, route: Route, *, amount_atomic: int, recipient: str, sender: str | None, + mint_mode: str = "public") -> Plan: + """Build the ``Plan`` for an Ethereum-origin route (mirrors veil ``prepare`` steps, brief §2.1). + + Plan 4's ``lifecycle.prepare`` is the Tier-1 entry point; this helper is the Tier-2 + path so ``bridge.eth.*`` calls carry a checkpointable plan without importing lifecycle. + """ + source: Asset = registry.asset(route.source_asset_id) + destination: Asset = registry.asset(route.destination_asset_id) + if mint_mode not in ("public", "record", "private"): + raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") + if mint_mode != "public" and route.protocol != "xreserve": + raise BridgeError("mint_mode other than public applies only to xReserve deposits to Aleo") + if amount_atomic <= 0: + raise BridgeError("amount_atomic must be positive") + if route.protocol == "xreserve": + steps = (Step("source-approval", "approve", "evm-wallet", False), + Step("source-deposit", "deposit", "evm-wallet", True), + Step("deposit-attestation", "wait-attestation", "protocol", False), + Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) + else: + steps = tuple([Step("source-approval", "approve", "evm-wallet", False)] if source.kind == "token" else []) + ( + Step("source-dispatch", "dispatch", "evm-wallet", True), + Step("message-delivery", "wait-delivery", "protocol", False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) + return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, + environment=route.environment, source_asset_id=source.id, destination_asset_id=destination.id, + amount=format_decimal_amount(amount_atomic, source.decimals), amount_atomic=amount_atomic, + recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) + + class EthModule: - """Completed in Task 2.""" + """``bridge.eth`` — Ethereum-origin Hyperlane and xReserve actions (reads return values, writes return ``EvmCall``).""" def __init__(self, bridge: Any, conn: Ethereum) -> None: - self._bridge, self._conn = bridge, conn + self.bridge = bridge + self.conn = conn + self.registry: Registry = bridge.registry + self.network: str = bridge.network # "mainnet" | "testnet" → aleo. for encoders + self.chain: Chain = self.registry.chain(EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py new file mode 100644 index 00000000..a13db40f --- /dev/null +++ b/bridge-sdk/tests/test_evm_call.py @@ -0,0 +1,150 @@ +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge._calls import EvmCall, EvmOutcome, EvmStep +from aleo_bridge._evm_abi import ERC20_ABI, WARP_ROUTE_ABI +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import BridgeError, ConfigurationError +from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import DispatchReceipt, Receipt, Status +from tests.fakes.fake_web3 import fake_web3, tx_hash_for + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +WBTC = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" +ROUTER = "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:ethereum/usdc->aleo/usdcx") + + +def make_call(w3, *, sender=None, store=None, approvals=1): + conn = Ethereum(w3=w3, private_key=KEY) + plan = _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=100_000, recipient=ALEO, sender=sender) + token = w3.eth.contract(address=Web3.to_checksum_address(WBTC), abi=ERC20_ABI) + warp = w3.eth.contract(address=Web3.to_checksum_address(ROUTER), abi=WARP_ROUTE_ABI) + + def steps(owner): + out = [EvmStep("approve", token.address, token.encode_abi("approve", args=[warp.address, 100_000]), 0) + for _ in range(approvals)] + out.append(EvmStep("main", warp.address, warp.encode_abi("transferRemote", args=[1634493807, b"\x11" * 32, 100_000]), 50_000)) + return out + + def finish(outcome: EvmOutcome) -> DispatchReceipt: + status = Status.DELIVERY_PENDING if outcome.status == "CONFIRMED" else Status(outcome.status) + rid = outcome.source_tx_id or (outcome.approval_tx_ids[-1] if outcome.approval_tx_ids else "unsent") + receipt = Receipt(id=rid, protocol="hyperlane", status=status, source_tx_id=outcome.source_tx_id, + protocol_state={"routeId": ROUTE.id, "approvalTxIds": list(outcome.approval_tx_ids), + "sourceSender": outcome.sender}) + return DispatchReceipt(transaction_id=rid, route_id=ROUTE.id, message_id=None, amount_atomic=100_000, receipt=receipt) + + return EvmCall(conn, plan=plan, registry=DEFAULT_REGISTRY, steps=steps, finish=finish, store=store), plan + + +def test_plan_for_hyperlane_and_xreserve_shapes(): + plan = _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) + assert plan.route_id == ROUTE.id and plan.registry_version == DEFAULT_REGISTRY.version + assert plan.protocol == "hyperlane" and plan.environment == "mainnet" + assert plan.source_asset_id == "ethereum/wbtc" and plan.destination_asset_id == "aleo/wbtc" + assert plan.amount == "0.001" and plan.amount_atomic == 100_000 and plan.recipient == ALEO + assert plan.sender == ACCT.address and plan.mint_mode == "public" + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-approval", "approve", "evm-wallet", False), + ("source-dispatch", "dispatch", "evm-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + eth_plan = _plan_for(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth"), + amount_atomic=100, recipient=ALEO, sender=None) + assert [s.id for s in eth_plan.steps][0] == "source-dispatch" # native: no approval step + assert eth_plan.amount == "0.0000000000000001" + private = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None, mint_mode="private") + assert private.amount == "2" and private.mint_mode == "private" + assert [(s.id, s.executor, s.irreversible) for s in private.steps] == [ + ("source-approval", "evm-wallet", False), ("source-deposit", "evm-wallet", True), + ("deposit-attestation", "protocol", False), ("destination-mint", "aleo-wallet", False), + ] + public = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None) + assert public.steps[-1].executor == "protocol" + with pytest.raises(BridgeError, match="mint_mode"): + _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=1, recipient=ALEO, sender=None, mint_mode="private") + + +def test_build_returns_unsigned_dicts_in_order_without_sending(): + w3 = fake_web3() + call, _ = make_call(w3) + txs = call.build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] + assert [t["value"] for t in txs] == [0, 50_000] + assert [t["nonce"] for t in txs] == [0, 1] + assert all(t["from"] == ACCT.address and t["chainId"] == 1 for t in txs) + assert txs[1]["data"].startswith("0x") and w3.provider.sent == [] + + +def test_send_runs_approve_then_main_and_checkpoints_each_hash_before_polling(): + w3 = fake_web3() + call, plan = make_call(w3) + seen = [] + result = call.send(on_checkpoint=seen.append, poll_seconds=0.001) + assert isinstance(result, DispatchReceipt) and result.receipt.status == Status.DELIVERY_PENDING + assert [t["to"] for t in w3.provider.sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] + assert w3.provider.sent[1]["value"] == 50_000 + assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and result.receipt.source_tx_id == tx_hash_for(2) + assert [cp.source for cp in seen] == [ + {"approvalTransactionIds": [tx_hash_for(1)]}, + {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2)}, + {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2)}, + ] + assert all(cp.route == {"id": plan.route_id, "registryVersion": plan.registry_version} for cp in seen) + assert seen[0].intent["sender"] == ACCT.address + + +def test_approval_timeout_returns_pending_and_stops(): + w3 = fake_web3() + w3.provider.pending.add(tx_hash_for(1)) + call, _ = make_call(w3) + result = call.send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and len(w3.provider.sent) == 1 + + +def test_main_timeout_returns_source_confirming(): + w3 = fake_web3() + w3.provider.pending.add(tx_hash_for(1)) + call, _ = make_call(w3, approvals=0) + result = call.send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) + + +def test_wait_false_returns_after_first_broadcast(): + w3 = fake_web3() + call, _ = make_call(w3) + result = call.send(wait=False) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and len(w3.provider.sent) == 1 + + +def test_reverted_transaction_raises(): + w3 = fake_web3() + w3.provider.reverted.add(tx_hash_for(1)) + call, _ = make_call(w3) + with pytest.raises(BridgeError, match=f"EVM transaction reverted: {tx_hash_for(1)}"): + call.send(poll_seconds=0.001) + + +def test_plan_sender_must_match_connected_account(): + w3 = fake_web3() + call, _ = make_call(w3, sender="0x0000000000000000000000000000000000000001") + with pytest.raises(ConfigurationError, match="does not match connected account"): + call.send() + assert w3.provider.sent == [] + + +def test_bound_store_saves_every_checkpoint(tmp_path): + w3 = fake_web3() + store = FileCheckpointStore(tmp_path) + call, _ = make_call(w3, store=store) + result = call.send(poll_seconds=0.001) + ids = {cp.id for cp in store.list()} + assert tx_hash_for(1) in ids and result.receipt.id in ids From 794a7d135f7581df7b443894c707194e9ea3bfd3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:49:44 -0400 Subject: [PATCH 23/94] fix(bridge-sdk): AleoCall.__repr__ no longer leaks record/nonce literals 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. --- bridge-sdk/python/aleo_bridge/_calls.py | 4 +++- bridge-sdk/tests/test_calls.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index c8af4d87..c87ff6dc 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -95,7 +95,9 @@ def __init__(self, aleo: Any, bound: Any, build_result: Callable[[str, list[str] self._imports_registered = False def __repr__(self) -> str: - return f"AleoCall({self.program_id}/{self.function_name}, inputs={self.inputs!r})" + # Never print .inputs here: a record plaintext (private_burn arg 0) or a secret nonce + # (private_mint arg 3) can be an input literal, and repr() output tends to end up in logs. + return f"AleoCall({self.program_id}/{self.function_name}, inputs={len(self.inputs)} literals)" @property def program_id(self) -> str: diff --git a/bridge-sdk/tests/test_calls.py b/bridge-sdk/tests/test_calls.py index e8027a81..d8a21c8a 100644 --- a/bridge-sdk/tests/test_calls.py +++ b/bridge-sdk/tests/test_calls.py @@ -41,6 +41,19 @@ def test_attributes_and_simulate(fake_aleo): assert fake_aleo.submitted == [] and fake_aleo.delegated == [] +def test_repr_never_leaks_input_literals(fake_aleo): + # private_burn input 0 is a USDCx record plaintext; a secret nonce (private_mint input 3) is + # just as sensitive — repr() must never print .inputs, only a count. + record = "{ owner: aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px.private, amount: 5000000u128.private, _nonce: 7group.public }" + secret_nonce = "7scalar" + bound = fake_aleo.programs.get(PROGRAM).functions[FN](record, "2500000u128", "0u32", "[0field]", secret_nonce) + call = AleoCall(fake_aleo, bound, lambda tx_id, outs: (tx_id, outs)) + text = repr(call) + assert text == f"AleoCall({PROGRAM}/{FN}, inputs=5 literals)" + assert record not in text and secret_nonce not in text + assert call.inputs == [record, "2500000u128", "0u32", "[0field]", secret_nonce] # accessor still exposes them + + def test_prove_returns_prepared_tx_without_broadcast(fake_aleo): prepared = _call(fake_aleo).prove(priority_fee=5) assert isinstance(prepared, PreparedTx) and prepared.transaction_id == "at1built" From 56fad4644571b9e8de61c4e5782207b073225018 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:49:49 -0400 Subject: [PATCH 24/94] fix(bridge-sdk): Receipt.replace copies its dicts; to_progress raises 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). --- bridge-sdk/python/aleo_bridge/types.py | 8 +++++++- bridge-sdk/tests/test_types.py | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/types.py b/bridge-sdk/python/aleo_bridge/types.py index 1054b447..4cb22038 100644 --- a/bridge-sdk/python/aleo_bridge/types.py +++ b/bridge-sdk/python/aleo_bridge/types.py @@ -6,6 +6,8 @@ from enum import Enum from typing import Any +from .errors import ConfigurationError + class Status(str, Enum): PREPARED = "PREPARED" @@ -145,6 +147,10 @@ def __post_init__(self) -> None: self.status = Status(self.status) def replace(self, **changes: Any) -> "Receipt": + # Copy the mutable fields (never alias self's dicts) so mutating the returned Receipt + # can never reach back into the one it was derived from. + changes.setdefault("protocol_state", dict(self.protocol_state)) + changes.setdefault("next_action", None if self.next_action is None else dict(self.next_action)) return dataclasses.replace(self, **changes) @@ -160,7 +166,7 @@ def to_progress(plan: Plan, receipt: Receipt) -> Progress: """brief §2.5: SOURCE_SUBMISSION_PENDING→resume, DESTINATION_ACTION_REQUIRED→complete, COMPLETED→done, FAILED|EXPIRED→failed, everything else→wait. For FAILED|EXPIRED, derive error from protocol_state.""" if "routeId" not in receipt.protocol_state: - raise ValueError("Receipt.protocol_state must carry routeId") + raise ConfigurationError("Receipt.protocol_state must carry routeId") status = Status(receipt.status) next_val = _NEXT_BY_STATUS.get(status, "wait") diff --git a/bridge-sdk/tests/test_types.py b/bridge-sdk/tests/test_types.py index 47df0597..9c2a9271 100644 --- a/bridge-sdk/tests/test_types.py +++ b/bridge-sdk/tests/test_types.py @@ -5,6 +5,7 @@ import aleo_bridge from aleo_bridge import types +from aleo_bridge.errors import ConfigurationError from aleo_bridge.types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, Attestation, BridgeStatus, ChainStatus, DispatchReceipt, Fee, GasQuote, Plan, PreparedTx, PrivacyReceipt, Progress, Receipt, Status, Step, to_progress) @@ -72,7 +73,7 @@ def test_to_progress_error_derivation(): def test_to_progress_accepts_status_strings_and_requires_route_id(): receipt = Receipt(id="at1x", protocol="hyperlane", status="COMPLETED", protocol_state={"routeId": "r"}) assert to_progress(_plan(), receipt).next == "done" - with pytest.raises(ValueError, match="routeId"): + with pytest.raises(ConfigurationError, match="routeId"): to_progress(_plan(), Receipt(id="at1x", protocol="hyperlane", status=Status.COMPLETED)) @@ -91,6 +92,13 @@ def test_receipt_replace_and_defaults(): r2 = r.replace(status=Status.DESTINATION_ACTION_REQUIRED, next_action={"kind": "xreserve-private-mint", "chainId": "aleo"}) assert r2.status is Status.DESTINATION_ACTION_REQUIRED and r.status is Status.ATTESTATION_PENDING assert r2.protocol_state == {"routeId": "x"} and r2.next_action["kind"] == "xreserve-private-mint" + # replace() must copy protocol_state/next_action, never alias the source's dicts + r2.protocol_state["routeId"] = "mutated" + r2.next_action["kind"] = "mutated" + assert r.protocol_state == {"routeId": "x"} and r.next_action is None + r3 = r2.replace(status=Status.COMPLETED) # replace() with neither field explicit still copies, not aliases + r3.protocol_state["routeId"] = "again" + assert r2.protocol_state["routeId"] == "mutated" def test_result_dataclasses(): From 2d85e45615f220537730ab52b7dfb761e92554a3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:49:55 -0400 Subject: [PATCH 25/94] fix(bridge-sdk): freeze-list root check is fatal when unreadable _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. --- bridge-sdk/python/aleo_bridge/freezelist.py | 17 +++++++------ bridge-sdk/tests/test_client.py | 5 ++++ bridge-sdk/tests/test_freezelist.py | 28 ++++++++++++++++++++- bridge-sdk/tests/test_privacy.py | 6 ++++- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/freezelist.py b/bridge-sdk/python/aleo_bridge/freezelist.py index 286bb534..050f4184 100644 --- a/bridge-sdk/python/aleo_bridge/freezelist.py +++ b/bridge-sdk/python/aleo_bridge/freezelist.py @@ -122,9 +122,10 @@ def freeze_list_program(self, token_program: str) -> str: """The freeze-list program backing *token_program* (usually a token program that imports it).""" if token_program.endswith("freezelist.aleo"): return token_program + from aleo.facade.errors import AleoError, ProgramNotFound try: imports = self._bridge.program(token_program).imports - except Exception: + except (ProgramNotFound, AleoError): imports = [] for dep in imports: if str(dep).endswith("freezelist.aleo"): @@ -155,12 +156,14 @@ def leaves(self, program: str) -> list[str]: def _verified_tree(self, fl_program: str, leaves: list[str]) -> list[int]: tree = build_tree(generate_leaves(leaves), self._bridge.network) on_chain_root = self._bridge.mapping_value(fl_program, FREEZE_LIST_ROOT_MAPPING, CURRENT_ROOT_KEY) - if on_chain_root is not None: - computed_root = f"{tree[-1]}field" - if computed_root != on_chain_root: - raise ConfigurationError( - f"computed freeze-list root {computed_root} != on-chain root {on_chain_root} for {fl_program}; " - "refusing to build a proof") + if on_chain_root is None: + raise ConfigurationError( + f"{fl_program}/{FREEZE_LIST_ROOT_MAPPING}[{CURRENT_ROOT_KEY}] is unreadable; pass merkle_proof= explicitly") + computed_root = f"{tree[-1]}field" + if computed_root != on_chain_root: + raise ConfigurationError( + f"computed freeze-list root {computed_root} != on-chain root {on_chain_root} for {fl_program}; " + "refusing to build a proof") return tree def tree(self, program: str) -> list[int]: diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 5d4185f7..4a50b7d4 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -79,6 +79,11 @@ def test_mapping_value_returns_none_for_missing_program(fake_aleo): def test_amount_helpers_and_privacy_delegation(fake_aleo): + from aleo_bridge.freezelist import EMPTY_TREE_ROOT + + # unshield()'s default (unsupplied) merkle_proof= resolves through freezelist.exclusion_proof(), + # which now requires a readable on-chain root (item 4) — seed the empty-list root. + fake_aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} bridge = Bridge(fake_aleo) assert bridge.to_atomic("0.001", "aleo/wbtc") == 100_000 and bridge.from_atomic(100_000, ("aleo", "wbtc")) == "0.001" assert bridge.to_atomic("1", DEFAULT_REGISTRY.asset("ethereum/usdc")) == 1_000_000 diff --git a/bridge-sdk/tests/test_freezelist.py b/bridge-sdk/tests/test_freezelist.py index bbdd6b43..8df793c1 100644 --- a/bridge-sdk/tests/test_freezelist.py +++ b/bridge-sdk/tests/test_freezelist.py @@ -86,10 +86,17 @@ def test_pure_exclusion_proof_of_empty_tree_equals_veil_literal(): def test_freezelist_reads_mappings_and_builds_proof(bridge): mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) - assert bridge.freezelist.leaves(PROGRAM) == [] and bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR + assert bridge.freezelist.leaves(PROGRAM) == [] + # An unreadable on-chain root is fatal now (item 4): no silent fallback to an unverified proof. + with pytest.raises(ConfigurationError, match="unreadable"): + bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + mappings["freeze_list_root"] = {"1u8": f"{fl.EMPTY_TREE_ROOT}field"} + assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == fl.EMPTY_MERKLE_PROOF_PAIR mappings["freeze_list_last_index"] = {"true": "1u32"} mappings["freeze_list_index"] = {"0u32": A, "1u32": ZERO} assert bridge.freezelist.leaves(PROGRAM) == [A] # zero address filtered + # The root moved once the list gained a member; seed the fake with the matching root. + mappings["freeze_list_root"] = {"1u8": f"{fl.build_tree(fl.generate_leaves([A]), 'mainnet')[-1]}field"} assert bridge.freezelist.tree(PROGRAM)[:2] == [0, A_FIELD] one = "{ siblings: [" + f"{A_FIELD}field, 0field, " + ", ".join(["0field"] * 14) + "], leaf_index: 1u32 }" assert bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) == f"[{one}, {one}]" @@ -97,6 +104,14 @@ def test_freezelist_reads_mappings_and_builds_proof(bridge): bridge.freezelist.exclusion_proof(A, PROGRAM) +def test_exclusion_proof_raises_when_root_unreadable(bridge): + # No usdcx_freezelist.aleo/freeze_list_root mapping at all — must raise, never fall back. + with pytest.raises(ConfigurationError, match=r"freeze_list_root\[1u8\] is unreadable"): + bridge.freezelist.exclusion_proof(RECIPIENT, PROGRAM) + with pytest.raises(ConfigurationError, match="unreadable"): + bridge.freezelist.tree(PROGRAM) + + def test_freezelist_last_index_parsing_is_not_rstrip(bridge): mappings = bridge.aleo.mappings.setdefault(FREEZE_LIST_PROGRAM, {}) mappings["freeze_list_last_index"] = {"true": "12u32"} @@ -121,6 +136,17 @@ def test_freeze_list_program_raises_for_unknown_program(bridge): bridge.freezelist.freeze_list_program("arc20_wbtc.aleo") +def test_freeze_list_program_propagates_unrelated_exceptions(bridge, monkeypatch): + # Only ProgramNotFound/AleoError fall back to the static table; a transient failure of any + # other type (e.g. a network hiccup) must propagate, not be swallowed as "no imports". + def boom(program_id): + raise RuntimeError("rpc hiccup") + + monkeypatch.setattr(bridge, "program", boom) + with pytest.raises(RuntimeError, match="rpc hiccup"): + bridge.freezelist.freeze_list_program(PROGRAM) + + def test_leaves_reads_from_freeze_list_program_not_token_program(bridge): # the token program's own mappings are deliberately wrong, to prove they are never consulted bridge.aleo.mappings[PROGRAM]["freeze_list_last_index"] = {"true": "0u32"} diff --git a/bridge-sdk/tests/test_privacy.py b/bridge-sdk/tests/test_privacy.py index d6e4965a..43141339 100644 --- a/bridge-sdk/tests/test_privacy.py +++ b/bridge-sdk/tests/test_privacy.py @@ -1,7 +1,7 @@ import pytest from aleo_bridge.errors import ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, UnsupportedRouteError -from aleo_bridge.freezelist import EMPTY_MERKLE_PROOF_PAIR +from aleo_bridge.freezelist import EMPTY_MERKLE_PROOF_PAIR, EMPTY_TREE_ROOT from aleo_bridge.privacy import record_amount from aleo_bridge.types import PrivacyReceipt from tests.conftest import SIGNER, USDCX_RECORD, USDCX_RECORD_SMALL @@ -56,6 +56,7 @@ def test_unshield_arc20_recipient_must_match_caller(bridge): def test_unshield_arc22_defaults_to_signer_record_and_empty_proof(bridge): + bridge.aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} call = bridge.privacy.unshield("aleo/usdcx", amount="2.5") assert (call.program_id, call.function_name) == ("usdcx_stablecoin.aleo", "transfer_private_to_public") assert call.inputs[:3] == [SIGNER, "2500000u128", USDCX_RECORD] @@ -92,6 +93,9 @@ def test_select_record_reports_largest_available(bridge): def test_private_burn_defaults_resolve_through_privacy_and_freezelist(bridge): ONE_LIT = "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" + # The freeze-list root must be readable on chain for the default (unsupplied) merkle_proof= + # to resolve — an unreadable root is now fatal (see test_freezelist.py). + bridge.aleo.mappings.setdefault("usdcx_freezelist.aleo", {})["freeze_list_root"] = {"1u8": f"{EMPTY_TREE_ROOT}field"} call = bridge.xreserve.burn("0x0000000000000000000000000000000000000001", amount="2.5") assert call.inputs == [USDCX_RECORD, "2500000u128", "0u32", ONE_LIT, EMPTY_MERKLE_PROOF_PAIR] assert bridge.aleo.record_queries[-1]["program"] == "usdcx_stablecoin.aleo" From 157978c57a7f6aaae7f103fa2bb16f95c4333144 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:50:00 -0400 Subject: [PATCH 26/94] fix(bridge-sdk): profile creation is exclusive (O_EXCL); home dir mode 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. --- bridge-sdk/python/aleo_bridge/profile.py | 26 ++++++++++++---------- bridge-sdk/tests/test_profile.py | 28 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/profile.py b/bridge-sdk/python/aleo_bridge/profile.py index 0ac2ab6b..40d749c4 100644 --- a/bridge-sdk/python/aleo_bridge/profile.py +++ b/bridge-sdk/python/aleo_bridge/profile.py @@ -18,15 +18,6 @@ _CHECKPOINTS = "checkpoints" -def _write_private(path: Path, payload: dict[str, Any]) -> None: - """Owner-only file written atomically (no umask window, no torn reads).""" - tmp = path.with_suffix(path.suffix + ".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(payload, indent=1)) - os.replace(tmp, path) - - def _initial_key(network: str) -> tuple[str, str]: """(private_key, address): imported from BRIDGE_PRIVATE_KEY / BRIDGE_PRIVATE_KEY_FILE, else freshly random.""" import aleo @@ -69,10 +60,23 @@ def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "mai path.chmod(0o600) # heal a loose mode on load profile = cls(home_path, json.loads(path.read_text())) else: - home_path.mkdir(parents=True, exist_ok=True) + if home_path.is_dir(): + home_path.chmod(0o700) # heal a loose mode on an existing dir + else: + home_path.mkdir(parents=True, exist_ok=True, mode=0o700) + home_path.chmod(0o700) # mkdir's mode is subject to umask; make it exact private_key, address = _initial_key(network) data = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} - _write_private(path, data) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + # Lost the creation race to another process — adopt the winner's key rather than + # clobbering its (already in-use) profile. + path.chmod(0o600) + data = json.loads(path.read_text()) + else: + with os.fdopen(fd, "w") as handle: + handle.write(json.dumps(data, indent=1)) profile = cls(home_path, data) profile.checkpoint_dir.mkdir(parents=True, exist_ok=True) return profile diff --git a/bridge-sdk/tests/test_profile.py b/bridge-sdk/tests/test_profile.py index 416589f2..30d61ea4 100644 --- a/bridge-sdk/tests/test_profile.py +++ b/bridge-sdk/tests/test_profile.py @@ -46,3 +46,31 @@ def test_default_home_and_tilde_expansion(tmp_path, monkeypatch): def test_profile_rejects_unknown_network(tmp_path): with pytest.raises(ConfigurationError, match="network"): Profile.load_or_create(tmp_path / "bad", network="devnet") + + +def test_profile_creation_is_exclusive_loser_adopts_winner_key(tmp_path, monkeypatch): + """Two processes racing Profile.load_or_create() on a fresh home must not clobber each other: + the exclusive create (O_EXCL) means the loser adopts the winner's already-written key.""" + from aleo import mainnet as net + + monkeypatch.delenv("BRIDGE_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_PRIVATE_KEY_FILE", raising=False) + home = tmp_path / "home" + winner_key = net.PrivateKey.random() + winner_address = str(winner_key.address) + + def racing_initial_key(network): + # Simulate another process winning the race: it creates the home dir and profile.json + # before this process gets to its own exclusive-open attempt. + home.mkdir(parents=True, exist_ok=True, mode=0o700) + (home / "profile.json").write_text(json.dumps({ + "address": winner_address, "private_key": str(winner_key), "network": network, "endpoint": DEFAULT_ENDPOINT, + })) + loser_key = net.PrivateKey.random() # must NOT end up written or returned + return str(loser_key), str(loser_key.address) + + monkeypatch.setattr("aleo_bridge.profile._initial_key", racing_initial_key) + profile = Profile.load_or_create(home) + assert profile.address == winner_address + assert json.loads((home / "profile.json").read_text())["address"] == winner_address + assert stat.S_IMODE(os.stat(home).st_mode) == 0o700 From 87b16768241b87a9ee5e3c6f1ca4355e3f663aa8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:50:05 -0400 Subject: [PATCH 27/94] fix(bridge-sdk): xreserve.burn validates mode before touching chain state 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. --- bridge-sdk/python/aleo_bridge/hyperlane.py | 14 ++++--------- bridge-sdk/python/aleo_bridge/xreserve.py | 19 +++++++++--------- bridge-sdk/tests/test_xreserve.py | 23 ++++++++++++++++++++-- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/hyperlane.py b/bridge-sdk/python/aleo_bridge/hyperlane.py index 03d6cf19..bd038089 100644 --- a/bridge-sdk/python/aleo_bridge/hyperlane.py +++ b/bridge-sdk/python/aleo_bridge/hyperlane.py @@ -6,13 +6,13 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any from . import encoding as enc from ._calls import AleoCall from .errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) -from .registry import Asset, Chain, Route +from .registry import Asset, Route from .types import DispatchReceipt, GasQuote, Receipt, Status from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -60,15 +60,9 @@ def __init__(self, bridge: "Bridge") -> None: self._bridge = bridge # ── route resolution ── - def _aleo_chain(self) -> Chain: - chains = [c for c in self._bridge.registry.chains(environment=self._bridge.environment) if c.family == "aleo"] - if len(chains) != 1: - raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self._bridge.environment}") - return chains[0] - def _aleo_asset(self, asset: Any) -> Asset: resolved = self._bridge.registry.asset(asset) - if resolved.chain_id != self._aleo_chain().id: + if resolved.chain_id != self._bridge.aleo_chain().id: raise UnsupportedRouteError( f"{resolved.id} is not an Aleo asset on {self._bridge.environment}; Aleo-origin Hyperlane transfers " "start from aleo/eth, aleo/wbtc, aleo/usdt or aleo/sol (use bridge.eth / bridge.sol for other origins)") @@ -126,7 +120,7 @@ def is_delivered(self, message_id: "str | bytes") -> bool: """Whether ``hyp_mailbox.aleo/deliveries`` holds the message (mapping presence is the acceptance signal).""" try: raw = enc.hex_to_bytes(message_id, 32) - except ValueError as exc: + except (ValueError, InvalidRecipientError) as exc: raise ConfigurationError("Hyperlane delivery requires a 32-byte message id") from exc return self._bridge.mapping_value(self._mailbox_program(), "deliveries", enc.hyperlane_delivery_key(raw)) is not None diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index b0e35ae8..c4318912 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -9,8 +9,8 @@ from ._calls import AleoCall from ._keccak import keccak256 from .circle import CircleClient -from .errors import (AttestationError, ConfigurationError, InvalidAmountError, RouteNotFoundError, - RouteUnavailableError, UnsupportedRouteError) +from .errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, + RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Route from .types import Attestation, BurnReceipt, MintReceipt, Receipt, Status from .units import format_decimal_amount, resolve_amount @@ -30,14 +30,8 @@ def __init__(self, bridge: "Bridge") -> None: self.circle_session: Any = None # injectable HTTP session (tests); None → requests.Session() # ── routes ── - def _aleo_chain_id(self) -> str: - chains = [c for c in self._bridge.registry.chains(environment=self._bridge.environment) if c.family == "aleo"] - if len(chains) != 1: - raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self._bridge.environment}") - return chains[0].id - def _single(self, direction: str) -> Route: - registry, aleo = self._bridge.registry, self._aleo_chain_id() + registry, aleo = self._bridge.registry, self._bridge.aleo_chain().id matches = [r for r in registry.routes(protocol="xreserve", include_unavailable=True, environment=self._bridge.environment) if registry.asset(r.destination_asset_id if direction == "inbound" else r.source_asset_id).chain_id == aleo] if not matches: @@ -102,6 +96,11 @@ def burn(self, recipient: str, *, amount: Any = None, amount_atomic: int | None """Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle.""" + if mode not in BURN_MODES: + raise ConfigurationError(f"Unsupported USDCx burn mode {mode!r}; expected one of {BURN_MODES}") + if mode != "private" and (record is not None or merkle_proof is not None): + raise ConfigurationError( + f"mode={mode!r} burns the public balance; record=/merkle_proof= only apply to mode='private'") route = self._validated(self.outbound_route(), direction="burn") source = self._bridge.registry.asset(route.source_asset_id) atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) @@ -185,7 +184,7 @@ def is_delivered(self, nonce: "str | bytes", *, route: Route | None = None) -> b route = route if route is not None else self.inbound_route() try: raw = enc.hex_to_bytes(nonce, 32) - except ValueError as exc: + except (ValueError, InvalidRecipientError) as exc: raise ConfigurationError("xReserve delivery requires a 32-byte deposit nonce") from exc value = self._bridge.mapping_value(route.meta_str("bridgeProgram"), "nullifier", enc.u8_array_literal(raw)) return value is not None and value.strip() == "true" diff --git a/bridge-sdk/tests/test_xreserve.py b/bridge-sdk/tests/test_xreserve.py index 7581d016..5c699674 100644 --- a/bridge-sdk/tests/test_xreserve.py +++ b/bridge-sdk/tests/test_xreserve.py @@ -1,8 +1,8 @@ import pytest from aleo_bridge import encoding as enc -from aleo_bridge.errors import (AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError, - UnsupportedRouteError) +from aleo_bridge.errors import (AttestationError, BridgeError, ConfigurationError, InvalidAmountError, + InvalidRecipientError, UnsupportedRouteError) from aleo_bridge.registry import DEFAULT_REGISTRY as REG from aleo_bridge.types import Attestation, BurnReceipt, MintReceipt, Status from tests.conftest import NULLIFIED_NONCE, USDCX_RECORD @@ -72,6 +72,20 @@ def test_burn_builds_call_and_receipt(bridge): assert (public.program_id, public.function_name, public.inputs) == ("usdcx_bridge_v2.aleo", "burn_public_as_signer", ["3000000u128", "0u32", ONE_LIT]) +def test_burn_validates_mode_before_any_amount_or_chain_work(bridge): + with pytest.raises(ConfigurationError, match="Unsupported USDCx burn mode"): + bridge.xreserve.burn(EVM1, mode="unknown") # no amount= given: an amount check first would raise a different error + assert bridge.aleo.record_queries == [] # never reached the record scan + + +def test_burn_rejects_record_or_merkle_proof_for_public_modes(bridge): + with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + bridge.xreserve.burn(EVM1, amount="2.5", mode="public", record=USDCX_RECORD) + with pytest.raises(ConfigurationError, match="record=/merkle_proof="): + bridge.xreserve.burn(EVM1, amount="2.5", mode="public-as-signer", merkle_proof="[x]") + assert bridge.aleo.record_queries == [] + + def _attested(bridge, nonce="7scalar", recipient=RECIPIENT) -> Attestation: hook = bridge.xreserve.hook_data("private", recipient, nonce) payload = bytes.fromhex("5a2e0acd00000001") + bytes(228) + bytes.fromhex("00000041") + hook @@ -138,6 +152,11 @@ class R: status_code = 404 assert session.urls[-1].startswith("https://xreserve-api-testnet.circle.com/v1/attestations/0x33") +def test_get_attestation_rejects_non_hex_message_hash(bridge): + with pytest.raises(BridgeError): + bridge.xreserve.get_attestation("nope") + + def test_is_delivered_reads_bridge_program_nullifier(bridge): assert bridge.xreserve.is_delivered(NULLIFIED_NONCE) is True assert bridge.xreserve.is_delivered("0x" + NULLIFIED_NONCE.hex()) is True From 4fdc2d84e5f132d092f407fa3ceda5c4ad40c7c4 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:50:15 -0400 Subject: [PATCH 28/94] fix(bridge-sdk): triaged minors from the plan-1 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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_.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. --- bridge-sdk/python/aleo_bridge/circle.py | 6 +++--- bridge-sdk/python/aleo_bridge/client.py | 12 ++++-------- bridge-sdk/python/aleo_bridge/encoding.py | 19 ++++++++++--------- bridge-sdk/python/aleo_bridge/registry.py | 2 +- bridge-sdk/python/aleo_bridge/units.py | 2 +- bridge-sdk/tests/test_encoding.py | 21 ++++++++++++++------- bridge-sdk/tests/test_registry.py | 2 ++ bridge-sdk/tests/test_units.py | 6 ++++++ 8 files changed, 41 insertions(+), 29 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/circle.py b/bridge-sdk/python/aleo_bridge/circle.py index e346e046..be07c4c0 100644 --- a/bridge-sdk/python/aleo_bridge/circle.py +++ b/bridge-sdk/python/aleo_bridge/circle.py @@ -7,7 +7,7 @@ from . import encoding as enc from ._keccak import keccak256 -from .errors import AttestationError, ConfigurationError +from .errors import AttestationError, ConfigurationError, InvalidRecipientError from .types import Attestation @@ -24,7 +24,7 @@ def __init__(self, base_url: str, session: Any = None, timeout: float = 30.0) -> def get_attestation(self, message_hash_hex: str) -> Attestation | None: try: digest = enc.hex_to_bytes(message_hash_hex, 32) - except ValueError as exc: + except (ValueError, InvalidRecipientError) as exc: raise AttestationError(f"Circle attestation lookup needs a 32-byte message hash, got {message_hash_hex!r}") from exc try: response = self._session.get(f"{self.base_url}/{enc.to_hex(digest)}", timeout=self.timeout) @@ -45,7 +45,7 @@ def get_attestation(self, message_hash_hex: str) -> Attestation | None: payload = enc.hex_to_bytes(value["payload"], enc.XRESERVE_PAYLOAD_BYTES) signature = enc.hex_to_bytes(value["attestation"], enc.HOOK_DATA_BYTES) echoed = enc.hex_to_bytes(value["messageHash"], 32) - except (KeyError, TypeError, ValueError) as exc: + except (KeyError, TypeError, ValueError, InvalidRecipientError) as exc: raise AttestationError("Circle attester returned an invalid response (payload/attestation/messageHash)") from exc if echoed != digest: raise AttestationError("Circle attester echoed a different message hash than requested") diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 5b026152..52c70bc4 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -43,6 +43,8 @@ def parse_uint_literal(value: str) -> int: def balance_program(asset: Asset) -> str | None: """Program whose ``balances`` mapping holds *asset*'s public balance (None for ALEO credits / no locator).""" + # Verified on chain 2026-09-17: the warp/xreserve programs mint/burn via arc20_.aleo's + # mint_public/burn_public, so this IS the ledger transfer_remote (and xreserve burns) spend. if asset.locator is None or asset.locator.kind != "aleo-program" or asset.locator.value == "credits.aleo": return None return asset.privacy.program if asset.privacy is not None else asset.locator.value @@ -93,18 +95,12 @@ def checkpoints_from_env() -> Any: directory = os.environ.get("BRIDGE_CHECKPOINT_DIR") if not directory: return None - try: - from .checkpoint import FileCheckpointStore # plan 4 - except ImportError as exc: - raise ConfigurationError("BRIDGE_CHECKPOINT_DIR needs the checkpoint store that arrives with plan 4; unset it for now") from exc + from .checkpoint import FileCheckpointStore return FileCheckpointStore(directory) def _checkpoints_for_profile(profile: Profile) -> Any: - try: - from .checkpoint import FileCheckpointStore # plan 4 - except ImportError: - return None + from .checkpoint import FileCheckpointStore return FileCheckpointStore(profile.checkpoint_dir) diff --git a/bridge-sdk/python/aleo_bridge/encoding.py b/bridge-sdk/python/aleo_bridge/encoding.py index 63764b2e..725322a9 100644 --- a/bridge-sdk/python/aleo_bridge/encoding.py +++ b/bridge-sdk/python/aleo_bridge/encoding.py @@ -12,7 +12,7 @@ from ._base58 import b58decode from ._keccak import keccak256 -from .errors import AttestationError, ConfigurationError, InvalidRecipientError +from .errors import AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" BECH32M_CONST = 0x2BC830A3 @@ -37,11 +37,11 @@ def hex_to_bytes(value: "str | bytes | bytearray | memoryview", expected_len: "i try: data = bytes.fromhex(text) except ValueError as exc: - raise ValueError(f"Not hexadecimal: {value!r}") from exc + raise InvalidRecipientError(f"Not hexadecimal: {value!r}") from exc else: data = bytes(value) if expected_len is not None and len(data) != expected_len: - raise ValueError(f"Expected {expected_len} bytes, got {len(data)}") + raise InvalidRecipientError(f"Expected {expected_len} bytes, got {len(data)}") return data @@ -163,7 +163,7 @@ def bytes32_to_u128_limbs(data: bytes) -> tuple[int, int]: """Two little-endian u128 limbs over ``bytes[0:16]`` and ``bytes[16:32]``.""" raw = bytes(data) if len(raw) != 32: - raise ValueError(f"Hyperlane recipient limbs need exactly 32 bytes, got {len(raw)}") + raise InvalidRecipientError(f"Hyperlane recipient limbs need exactly 32 bytes, got {len(raw)}") return int.from_bytes(raw[:16], "little"), int.from_bytes(raw[16:], "little") @@ -176,11 +176,11 @@ def evm_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: def solana_address_to_hyperlane_recipient(address: str) -> tuple[int, int]: try: raw = b58decode(address) - if len(raw) != 32: - raise ValueError("invalid public key width") - return bytes32_to_u128_limbs(raw) except ValueError as exc: raise InvalidRecipientError(f"Invalid Solana Hyperlane recipient: {address}") from exc + if len(raw) != 32: + raise InvalidRecipientError(f"Invalid Solana Hyperlane recipient: {address}") + return bytes32_to_u128_limbs(raw) def u128_pair_literal(limbs: tuple[int, int]) -> str: @@ -204,12 +204,13 @@ def hyperlane_delivery_key(message_id: bytes) -> str: def _uint_be(value: int, width: int) -> bytes: if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value >= 1 << (8 * width): - raise ValueError(f"Unsigned value does not fit in {width} bytes: {value!r}") + raise InvalidAmountError(f"Unsigned value does not fit in {width} bytes: {value!r}") return value.to_bytes(width, "big") def xreserve_deposit_nonce(source_domain: int, tx_hash: "bytes | str", log_index: int) -> bytes: """Circle's deposit nonce: ``keccak(abi.encode(uint32 domain) ‖ txHash ‖ abi.encode(uint256 logIndex))``.""" + _uint_be(source_domain, 4) # bound to uint32, consistent with the payload's remote_domain check below return keccak256(_uint_be(source_domain, 32) + hex_to_bytes(tx_hash, 32) + _uint_be(log_index, 32)) @@ -224,7 +225,7 @@ def xreserve_deposit_payload(*, amount: int, remote_domain: int, remote_token: b for name, value, width in (("remote_token", remote_token, 32), ("remote_recipient", remote_recipient, 32), ("nonce", nonce, 32), ("hook_data", hook_data, HOOK_DATA_BYTES)): if len(bytes(value)) != width: - raise ValueError(f"{name} must contain {width} bytes") + raise InvalidRecipientError(f"{name} must contain {width} bytes") out = bytearray(XRESERVE_PAYLOAD_BYTES) out[0:8] = _PAYLOAD_HEADER out[8:40] = _uint_be(amount, 32) diff --git a/bridge-sdk/python/aleo_bridge/registry.py b/bridge-sdk/python/aleo_bridge/registry.py index 46f517b1..0d8c4017 100644 --- a/bridge-sdk/python/aleo_bridge/registry.py +++ b/bridge-sdk/python/aleo_bridge/registry.py @@ -63,7 +63,7 @@ class Asset: def matches_address(self, value: str) -> bool: """Whether *value* matches this asset's chain address format (False when no regex is declared).""" - return bool(self.address_regex) and isinstance(value, str) and re.search(self.address_regex, value) is not None + return bool(self.address_regex) and isinstance(value, str) and re.fullmatch(self.address_regex, value) is not None @dataclass(frozen=True) diff --git a/bridge-sdk/python/aleo_bridge/units.py b/bridge-sdk/python/aleo_bridge/units.py index 79e647a3..fc276edb 100644 --- a/bridge-sdk/python/aleo_bridge/units.py +++ b/bridge-sdk/python/aleo_bridge/units.py @@ -6,7 +6,7 @@ from .errors import InvalidAmountError -_DECIMAL_RE = re.compile(r"^(\d+)(?:\.(\d+))?$") +_DECIMAL_RE = re.compile(r"^([0-9]+)(?:\.([0-9]+))?$") def _check_decimals(decimals: int) -> None: diff --git a/bridge-sdk/tests/test_encoding.py b/bridge-sdk/tests/test_encoding.py index 60b2b1ca..0940f8a8 100644 --- a/bridge-sdk/tests/test_encoding.py +++ b/bridge-sdk/tests/test_encoding.py @@ -1,7 +1,7 @@ import pytest from aleo_bridge import encoding as enc -from aleo_bridge.errors import AttestationError, ConfigurationError, InvalidRecipientError +from aleo_bridge.errors import AttestationError, ConfigurationError, InvalidAmountError, InvalidRecipientError RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" RECIPIENT_BYTES32 = "b102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" @@ -73,7 +73,7 @@ def test_solana_limbs(): def test_limbs_and_literals(): assert enc.bytes32_to_u128_limbs(bytes(31) + b"\x01") == (0, 1 << 120) - with pytest.raises(ValueError): + with pytest.raises(InvalidRecipientError): enc.bytes32_to_u128_limbs(bytes(31)) assert enc.u128_pair_literal((0, 1329227995784915872903807060280344576)) == \ "[0u128, 1329227995784915872903807060280344576u128]" @@ -81,7 +81,7 @@ def test_limbs_and_literals(): assert enc.u8_array_literal(bytes(31) + b"\x01") == "[" + ",".join(["0u8"] * 31 + ["1u8"]) + "]" assert enc.hex_to_bytes("0x00ff", 2) == b"\x00\xff" assert enc.hex_to_bytes(b"\x00\xff") == b"\x00\xff" - with pytest.raises(ValueError, match="32 bytes"): + with pytest.raises(InvalidRecipientError, match="32 bytes"): enc.hex_to_bytes("0x00ff", 32) assert enc.to_hex(b"\x00\xff") == "0x00ff" @@ -90,7 +90,7 @@ def test_hyperlane_delivery_key_vector(): # brief §3.7 vector key = enc.hyperlane_delivery_key(enc.hex_to_bytes(MESSAGE_ID, 32)) assert key == "{ id: [262854447642257427123071959211115528903u128, 102980212169860384794748804418278302317u128] }" - with pytest.raises(ValueError): + with pytest.raises(InvalidRecipientError): enc.hyperlane_delivery_key(b"\x00") @@ -137,16 +137,23 @@ def test_deposit_nonce_matches_abi_encoding_via_web3(): def test_deposit_payload_rejects_bad_widths(): good = dict(amount=1, remote_domain=1, remote_token=bytes(32), remote_recipient=bytes(32), local_token=EVM1, depositor=EVM1, max_fee=0, nonce=bytes(32), hook_data=bytes(65)) - with pytest.raises(ValueError, match="remote_token must contain 32 bytes"): + with pytest.raises(InvalidRecipientError, match="remote_token must contain 32 bytes"): enc.xreserve_deposit_payload(**{**good, "remote_token": bytes(31)}) - with pytest.raises(ValueError, match="hook_data must contain 65 bytes"): + with pytest.raises(InvalidRecipientError, match="hook_data must contain 65 bytes"): enc.xreserve_deposit_payload(**{**good, "hook_data": bytes(64)}) - with pytest.raises(ValueError, match="does not fit"): + with pytest.raises(InvalidAmountError, match="does not fit"): enc.xreserve_deposit_payload(**{**good, "remote_domain": 1 << 32}) with pytest.raises(InvalidRecipientError): enc.xreserve_deposit_payload(**{**good, "depositor": "0x1234"}) +def test_deposit_nonce_bounds_source_domain_to_uint32(): + tx_hash = bytes.fromhex("12" * 32) + assert enc.xreserve_deposit_nonce((1 << 32) - 1, tx_hash, 0) # max uint32 is fine + with pytest.raises(InvalidAmountError, match="does not fit"): + enc.xreserve_deposit_nonce(1 << 32, tx_hash, 0) + + def test_nonce_from_payload_rejects_bad_layout(): with pytest.raises(AttestationError, match="invalid deposit layout"): enc.xreserve_nonce_from_payload(bytes(305)) diff --git a/bridge-sdk/tests/test_registry.py b/bridge-sdk/tests/test_registry.py index 484e598c..114ff244 100644 --- a/bridge-sdk/tests/test_registry.py +++ b/bridge-sdk/tests/test_registry.py @@ -66,6 +66,8 @@ def test_assets_and_lookups(): assert REG.asset("solana/sol").address_regex == "^[1-9A-HJ-NP-Za-km-z]{32,44}$" assert REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001") assert not REG.asset("aleo/usdcx").matches_address("0xabc") + # re.fullmatch, not re.search: a trailing newline must not sneak past the "$" anchor + assert not REG.asset("ethereum/usdc").matches_address("0x0000000000000000000000000000000000000001\n") assert [a.id for a in REG.assets(chain="aleo")] == ["aleo/aleo", "aleo/usdcx", "aleo/eth", "aleo/wbtc", "aleo/usdt", "aleo/sol", "aleo/usad"] assert [a.id for a in REG.assets(symbol="aleo")] == ["aleo/aleo", "ethereum/aleo", "solana/aleo", "base/aleo", "hyperevm/aleo"] assert [a.id for a in REG.assets(environment="testnet")] == ["aleo-testnet/usdcx", "sepolia/usdc"] diff --git a/bridge-sdk/tests/test_units.py b/bridge-sdk/tests/test_units.py index 7defa08b..2f4ecfc6 100644 --- a/bridge-sdk/tests/test_units.py +++ b/bridge-sdk/tests/test_units.py @@ -21,6 +21,12 @@ def test_parse_decimal_amount_rejects(amount): parse_decimal_amount(amount, 6) +def test_parse_decimal_amount_rejects_unicode_digits(): + # Python's \d matches any Unicode decimal digit, not just ASCII 0-9; the regex must be [0-9] only. + with pytest.raises(InvalidAmountError): + parse_decimal_amount("٥٦", 6) # ARABIC-INDIC DIGIT FIVE/SIX — category Nd, not ASCII + + def test_parse_decimal_amount_rejects_bad_types_and_decimals(): with pytest.raises(InvalidAmountError): parse_decimal_amount(1.5, 6) # type: ignore[arg-type] From 6afbadfd35816c906cd50699ec9fe811f54e81d9 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 14:56:02 -0400 Subject: [PATCH 29/94] test(bridge-sdk): prove eth.py fee arithmetic, legacy gasPrice path, 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. --- bridge-sdk/tests/fakes/fake_web3.py | 44 ++++++++++++++++++------- bridge-sdk/tests/test_eth_connection.py | 25 ++++++++++++++ bridge-sdk/tests/test_evm_call.py | 39 +++++++++++++++++++++- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index 290f5f63..d9aae648 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -74,7 +74,13 @@ def deposited_log(xreserve: str, *, local_token: str, depositor: str, remote_rec def _decode_raw(raw: bytes) -> dict: - """Signed raw tx → {to, value, data, from}. Typed (0x02) and legacy envelopes.""" + """Signed raw tx → {to, value, data, from, nonce, gas, ...fee fields}. Typed (0x02) and legacy envelopes. + + Keeps whichever fee fields the sender actually filled in (``gasPrice`` for a + legacy/type-0 envelope, ``maxFeePerGas``/``maxPriorityFeePerGas`` for a type-2 + one) so tests can assert on the exact values ``eth.py``'s fee-filling logic + computed, not just that *some* transaction was sent. + """ from eth_account.typed_transactions import TypedTransaction sender = Account.recover_transaction(raw) @@ -87,7 +93,11 @@ def _decode_raw(raw: bytes) -> dict: fields = rlp.decode(raw, Transaction).as_dict() data = fields.get("data", b"") data_hex = data if isinstance(data, str) else "0x" + bytes(data).hex() - return {"to": to_checksum_address(fields["to"]), "value": int(fields.get("value", 0)), "data": data_hex, "from": sender} + tx = {"to": to_checksum_address(fields["to"]), "value": int(fields.get("value", 0)), "data": data_hex, "from": sender} + for key in ("nonce", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"): + if fields.get(key) is not None: + tx[key] = int(fields[key]) + return tx class FakeRpcProvider(BaseProvider): @@ -97,9 +107,10 @@ 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) -> None: + delivered: set[str] | None = None, legacy: bool = False) -> None: super().__init__() self.chain_id = chain_id + self.legacy = legacy # True: eth_getBlockByNumber omits baseFeePerGas self.eth_balances = {to_checksum_address(k): v for k, v in (eth_balances or {}).items()} self.token_balances = {(to_checksum_address(t), to_checksum_address(o)): v for (t, o), v in (token_balances or {}).items()} @@ -110,6 +121,8 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N self.sent: list[dict] = [] # {to, value, data, from, hash} in send order self.pending: set[str] = set() # hashes whose receipt stays None self.reverted: set[str] = set() # hashes whose receipt has status 0 + self.receipt_delay: dict[str, int] = {} # hash -> remaining polls that return None before mined + self.receipt_poll_counts: dict[str, int] = {} # hash -> eth_getTransactionReceipt calls seen for it self.receipt_logs: Callable[[dict], list[dict]] = lambda tx: [] # logs for a sent tx's receipt self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/fromBlock) self.transactions: dict[str, dict] = {} # extra eth_getTransactionByHash answers @@ -134,13 +147,16 @@ def make_request(self, method: str, params: Any) -> dict: return self._ok({"baseFeePerGas": [_hex(10**9)] * 2, "gasUsedRatio": [0.5], "oldestBlock": "0x1", "reward": [[_hex(10**8)]]}) if method == "eth_getBlockByNumber": - return self._ok({"number": _hex(self.block_number), "baseFeePerGas": _hex(10**9), "gasLimit": _hex(30_000_000), - "gasUsed": "0x0", "timestamp": "0x0", "hash": "0x" + "ab" * 32, "parentHash": "0x" + "00" * 32, - "transactions": [], "difficulty": "0x0", "extraData": "0x", "logsBloom": "0x" + "00" * 256, - "miner": ZERO_ADDRESS, "mixHash": "0x" + "00" * 32, "nonce": "0x0000000000000000", - "receiptsRoot": "0x" + "00" * 32, "sha3Uncles": "0x" + "00" * 32, "size": "0x1", - "stateRoot": "0x" + "00" * 32, "totalDifficulty": "0x0", - "transactionsRoot": "0x" + "00" * 32, "uncles": []}) + block = {"number": _hex(self.block_number), "gasLimit": _hex(30_000_000), + "gasUsed": "0x0", "timestamp": "0x0", "hash": "0x" + "ab" * 32, "parentHash": "0x" + "00" * 32, + "transactions": [], "difficulty": "0x0", "extraData": "0x", "logsBloom": "0x" + "00" * 256, + "miner": ZERO_ADDRESS, "mixHash": "0x" + "00" * 32, "nonce": "0x0000000000000000", + "receiptsRoot": "0x" + "00" * 32, "sha3Uncles": "0x" + "00" * 32, "size": "0x1", + "stateRoot": "0x" + "00" * 32, "totalDifficulty": "0x0", + "transactionsRoot": "0x" + "00" * 32, "uncles": []} + if not self.legacy: + block["baseFeePerGas"] = _hex(10**9) + return self._ok(block) if method == "eth_getTransactionCount": return self._ok(_hex(len(self.sent))) if method == "eth_estimateGas": @@ -158,7 +174,9 @@ def make_request(self, method: str, params: Any) -> dict: return self._ok(self._accept({"to": to_checksum_address(p["to"]), "value": value, "data": p.get("data", "0x"), "from": to_checksum_address(p["from"])})) if method == "eth_getTransactionReceipt": - return self._ok(self._receipt(params[0])) + h = params[0] + self.receipt_poll_counts[h] = self.receipt_poll_counts.get(h, 0) + 1 + return self._ok(self._receipt(h)) if method == "eth_getLogs": f = params[0] addr = f.get("address") @@ -189,6 +207,10 @@ def _accept(self, tx: dict) -> str: def _receipt(self, h: str) -> dict | None: if h in self.pending: return None + delay = self.receipt_delay.get(h, 0) + if delay > 0: + self.receipt_delay[h] = delay - 1 + return None if h in self.receipts: return self.receipts[h] sent = next((t for t in self.sent if t["hash"] == h), None) diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py index d64e0c59..2da57fb7 100644 --- a/bridge-sdk/tests/test_eth_connection.py +++ b/bridge-sdk/tests/test_eth_connection.py @@ -82,6 +82,31 @@ def test_local_account_path_signs_and_sends_raw(): assert "eth_sendRawTransaction" in w3.provider.methods and "eth_sendTransaction" not in w3.provider.methods sent = w3.provider.sent[0] assert sent["from"] == ACCT.address and sent["to"] == Web3.to_checksum_address(TO) and sent["value"] == 7 + # eth.py's fee-filling (EIP-1559 path, since the fake's eth_getBlockByNumber carries baseFeePerGas): + # nonce <- eth_getTransactionCount(sender, "pending") + # gas <- eth_estimateGas(...) * 12 // 10 (a 20% buffer) + # maxPriorityFeePerGas <- eth_maxPriorityFeePerGas + # maxFeePerGas <- baseFeePerGas * 2 + maxPriorityFeePerGas + assert sent["nonce"] == 0 + assert sent["gas"] == 150_000 * 12 // 10 + assert sent["maxPriorityFeePerGas"] == 10**8 + assert sent["maxFeePerGas"] == 10**9 * 2 + 10**8 + assert "gasPrice" not in sent + + +def test_legacy_gas_price_path_when_no_base_fee(): + """With no ``baseFeePerGas`` on the latest block (pre-EIP-1559 chain), eth.py falls back + to a plain ``gasPrice`` from ``eth_gasPrice`` and sets no 1559 fee fields.""" + from aleo_bridge.eth import Ethereum + + w3 = fake_web3(legacy=True) + conn = Ethereum(w3=w3, private_key=KEY) + h = conn.send_transaction({"to": TO, "value": 3, "data": "0x"}) + assert h == tx_hash_for(1) + sent = w3.provider.sent[0] + assert sent["gasPrice"] == 10**9 + assert "maxFeePerGas" not in sent and "maxPriorityFeePerGas" not in sent + assert "eth_maxPriorityFeePerGas" not in w3.provider.methods def test_sender_mismatch_is_refused(): diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index a13db40f..cb61cda8 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -85,9 +85,20 @@ def test_build_returns_unsigned_dicts_in_order_without_sending(): def test_send_runs_approve_then_main_and_checkpoints_each_hash_before_polling(): w3 = fake_web3() + # Make both receipts resolve only after a couple of pending polls, so the ordering + # test actually exercises "checkpoint fires, THEN polling happens" rather than a + # same-tick resolution that would pass even if the code checkpointed after polling. + w3.provider.receipt_delay[tx_hash_for(1)] = 2 + w3.provider.receipt_delay[tx_hash_for(2)] = 2 call, plan = make_call(w3) seen = [] - result = call.send(on_checkpoint=seen.append, poll_seconds=0.001) + poll_counts_at_checkpoint = [] + + def on_checkpoint(cp): + seen.append(cp) + poll_counts_at_checkpoint.append(dict(w3.provider.receipt_poll_counts)) + + result = call.send(on_checkpoint=on_checkpoint, poll_seconds=0.001) assert isinstance(result, DispatchReceipt) and result.receipt.status == Status.DELIVERY_PENDING assert [t["to"] for t in w3.provider.sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] assert w3.provider.sent[1]["value"] == 50_000 @@ -100,6 +111,32 @@ def test_send_runs_approve_then_main_and_checkpoints_each_hash_before_polling(): assert all(cp.route == {"id": plan.route_id, "registryVersion": plan.registry_version} for cp in seen) assert seen[0].intent["sender"] == ACCT.address + # Checkpoint-before-poll ordering, per hash: + # cp0 (approval broadcast) fires before any eth_getTransactionReceipt for hash1. + assert poll_counts_at_checkpoint[0].get(tx_hash_for(1), 0) == 0 + assert tx_hash_for(2) not in poll_counts_at_checkpoint[0] + # cp1 (main broadcast) fires after hash1 was fully polled to confirmation, but + # before any eth_getTransactionReceipt for hash2. + assert poll_counts_at_checkpoint[1].get(tx_hash_for(1), 0) > 0 + assert poll_counts_at_checkpoint[1].get(tx_hash_for(2), 0) == 0 + # cp2 (confirmed) fires only after hash2 has itself been polled. + assert poll_counts_at_checkpoint[2].get(tx_hash_for(2), 0) > 0 + # ... and each hash's poll count strictly increases after its own checkpoint fired + # (the receipt_delay=2 knob forces at least one more poll beyond the checkpoint tick). + assert w3.provider.receipt_poll_counts[tx_hash_for(1)] > poll_counts_at_checkpoint[0].get(tx_hash_for(1), 0) + assert w3.provider.receipt_poll_counts[tx_hash_for(2)] > poll_counts_at_checkpoint[1].get(tx_hash_for(2), 0) + + # Full RPC sequence: approve is sent and fully confirmed (>=1 receipt poll) before + # the main call is ever broadcast, and the main call is polled only afterwards. + relevant = [m for m in w3.provider.methods if m in ("eth_sendRawTransaction", "eth_getTransactionReceipt")] + first_send = relevant.index("eth_sendRawTransaction") + second_send = relevant.index("eth_sendRawTransaction", first_send + 1) + assert relevant[first_send] == "eth_sendRawTransaction" + between = relevant[first_send + 1:second_send] + assert between and all(m == "eth_getTransactionReceipt" for m in between) + after = relevant[second_send + 1:] + assert after and all(m == "eth_getTransactionReceipt" for m in after) + def test_approval_timeout_returns_pending_and_stops(): w3 = fake_web3() From f92e13c9f523094a3a4d783cd2716edd304126e6 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:01:09 -0400 Subject: [PATCH 30/94] feat(bridge-sdk): eth.quote_transfer_remote with native/collateral fee split and chain assert --- bridge-sdk/python/aleo_bridge/eth.py | 176 ++++++++++++++++++- bridge-sdk/tests/fakes/fake_web3.py | 14 ++ bridge-sdk/tests/test_eth_hyperlane_quote.py | 107 +++++++++++ 3 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/test_eth_hyperlane_quote.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 84fec33e..da3da84e 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -7,13 +7,18 @@ from __future__ import annotations import os +import re +from dataclasses import dataclass from typing import Any, Mapping -from ._evm_abi import EVM_CHAIN_BY_ENVIRONMENT -from .errors import BridgeError, ConfigurationError, MissingExtraError +from . import encoding +from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, WARP_ROUTE_ABI, ZERO_ADDRESS +from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InvalidAmountError, + InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, + RouteUnavailableError) from .registry import Asset, Chain, Registry, Route -from .types import Plan, Step -from .units import format_decimal_amount +from .types import EvmHyperlaneQuote, Fee, Plan, Step +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount def _web3(): @@ -191,6 +196,23 @@ def _plan_for(registry: Registry, route: Route, *, amount_atomic: int, recipient recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) +@dataclass(frozen=True) +class _HyperlaneQuote: + """Router-level facts behind an ``EvmHyperlaneQuote`` (addresses never leave the module).""" + + router: str + router_type: str # "native" | "collateral" + token: str | None # collateral ERC-20 + destination_domain: int + recipient_bytes32: bytes + amount_atomic: int + native_value_atomic: int + native_fee_atomic: int + token_amount_atomic: int # 0 on native routes + allowance_atomic: int | None + requires_approval_reset: bool + + class EthModule: """``bridge.eth`` — Ethereum-origin Hyperlane and xReserve actions (reads return values, writes return ``EvmCall``).""" @@ -201,5 +223,151 @@ def __init__(self, bridge: Any, conn: Ethereum) -> None: self.network: str = bridge.network # "mainnet" | "testnet" → aleo. for encoders self.chain: Chain = self.registry.chain(EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) + # -- resolution --------------------------------------------------------------------------- + + def _asset(self, ref: Any) -> Asset: + """Accept an ``Asset``, ``"chain/key"``, ``(chain, key)``, or a bare key/symbol on this chain.""" + if isinstance(ref, Asset): + return ref + if isinstance(ref, tuple) or (isinstance(ref, str) and "/" in ref): + return self.registry.asset(ref) + matches = [a for a in self.registry.assets(chain=self.chain.id) + if a.key.lower() == str(ref).lower() or a.symbol.lower() == str(ref).lower()] + if len(matches) != 1: + raise RouteNotFoundError(f"No unique asset {ref!r} on {self.chain.id}; use 'chain/key'") + return matches[0] + + def _hyperlane_route(self, asset: Asset) -> Route: + if asset.chain_id != self.chain.id: + raise RouteNotFoundError(f"{asset.id} is not on {self.chain.id}; bridge.eth drives {self.chain.id} only") + candidates = [r for r in self.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self.bridge.environment) + if r.source_asset_id == asset.id] + if not candidates: + if any(r.source_asset_id == asset.id for r in self.registry.routes(include_unavailable=True, + environment=self.bridge.environment)): + raise BridgeError(f"{asset.id} is not a Hyperlane route source; use deposit_usdc for xReserve") + raise RouteNotFoundError(f"No Hyperlane route from {asset.id}") + active = [r for r in candidates if r.availability == "active"] + if not active: + raise RouteUnavailableError(f"Hyperlane route is not executable ({candidates[0].availability}): {candidates[0].id}") + if len(active) > 1: + raise AmbiguousRouteError(f"{len(active)} active Hyperlane routes from {asset.id}; pass route=") + return active[0] + + def _xreserve_route(self) -> Route: + routes = [r for r in self.registry.routes(protocol="xreserve", environment=self.bridge.environment) + if self.registry.asset(r.source_asset_id).chain_id == self.chain.id] + if len(routes) != 1: + raise RouteNotFoundError(f"Expected exactly one xReserve deposit route from {self.chain.id}, found {len(routes)}") + if routes[0].availability != "active": + raise RouteUnavailableError(f"xReserve route is not executable: {routes[0].id}") + return routes[0] + + def _route_for_plan(self, plan: Plan) -> Route: + """Re-resolve the route from the live registry (invariant 1); never trust plan-carried addresses.""" + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") + route = self.registry.route(plan.route_id) + if route.source_asset_id != plan.source_asset_id or route.destination_asset_id != plan.destination_asset_id: + raise BridgeError(f"Plan assets do not match configured route {route.id}") + if route.availability != "active": + raise RouteUnavailableError(f"Route is not executable: {route.id}") + return route + + def assert_chain(self, route: Route) -> None: + expected = int(route.metadata["sourceChainId"]) + actual = self.conn.chain_id + if actual != expected: + raise ChainMismatchError(f"EVM connection is on chain {actual}; expected {expected} for {route.id}") + + def _recipient_bytes32(self, route: Route, recipient: str) -> bytes: + destination = self.registry.asset(route.destination_asset_id) + if destination.address_regex and not re.fullmatch(destination.address_regex, recipient): + raise InvalidRecipientError(f"Recipient does not match the {destination.chain_id} address format: {recipient}") + return encoding.aleo_address_to_bytes32(recipient) + + def _amount_atomic(self, route: Route, amount: Any, amount_atomic: int | None) -> int: + source = self.registry.asset(route.source_asset_id) + destination = self.registry.asset(route.destination_asset_id) + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=source.decimals) + if atomic <= 0: + raise InvalidAmountError("Amount must be positive") + parse_decimal_amount(format_decimal_amount(atomic, source.decimals), destination.decimals) # precision on both sides + return atomic + + def _owner(self, sender: str | None) -> str | None: + if sender is None: + return self.conn.address + return _web3().Web3.to_checksum_address(sender) + + # -- contracts ---------------------------------------------------------------------------- + + def _contract(self, address: str, abi: list) -> Any: + return self.conn.w3.eth.contract(address=_web3().Web3.to_checksum_address(address), abi=abi) + + def _erc20(self, address: str) -> Any: + return self._contract(address, ERC20_ABI) + + def _native_fee(self, amount_wei: int) -> Fee: + native = [a for a in self.registry.assets(chain=self.chain.id) if a.kind == "native"] + asset_id = native[0].id if native else f"{self.chain.id}/{self.chain.native_symbol.lower()}" + return Fee(kind="network", chain_id=self.chain.id, asset_id=asset_id, + amount=format_decimal_amount(amount_wei, 18), estimated=True) + + # -- Hyperlane quote ---------------------------------------------------------------------- + + def _quote_hyperlane(self, route: Route, recipient_bytes32: bytes, amount_atomic: int, owner: str | None) -> _HyperlaneQuote: + """Brief §3.1: chain assert → quoteTransferRemote → native/collateral split → allowance.""" + self.assert_chain(route) + Web3 = _web3().Web3 + meta = route.metadata + router = Web3.to_checksum_address(str(meta["routerAddress"])) + router_type = str(meta["routerType"]) + destination_domain = int(meta["destinationDomain"]) + quotes = self._contract(router, WARP_ROUTE_ABI).functions.quoteTransferRemote( + destination_domain, recipient_bytes32, amount_atomic).call() + native_value = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == ZERO_ADDRESS) + if router_type == "native": + if native_value < amount_atomic: + raise BridgeError("Native Hyperlane quote does not cover the transfer amount") + return _HyperlaneQuote(router, "native", None, destination_domain, recipient_bytes32, amount_atomic, + native_value, native_value - amount_atomic, 0, None, False) + if router_type != "collateral": + raise RouteUnavailableError(f"Hyperlane route has an invalid routerType {router_type!r}: {route.id}") + token = Web3.to_checksum_address(str(meta["tokenAddress"])) + token_amount = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == token) + if token_amount < amount_atomic: + raise BridgeError("Collateral Hyperlane quote does not cover the transfer amount") + allowance = int(self._erc20(token).functions.allowance(owner, router).call()) if owner else None + return _HyperlaneQuote(router, "collateral", token, destination_domain, recipient_bytes32, amount_atomic, + native_value, native_value, token_amount, allowance, + meta.get("requiresApprovalReset") is True) + + def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + route: Route | None = None, sender: str | None = None) -> EvmHyperlaneQuote: + """Quote an Ethereum → Aleo Hyperlane transfer without signing. + + Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so + ``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): + ``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 + allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + """ + route = route or self._hyperlane_route(self._asset(asset)) + if route.protocol != "hyperlane": + raise BridgeError(f"{route.id} is not a Hyperlane route; use quote_deposit_usdc for xReserve") + atomic = self._amount_atomic(route, amount, amount_atomic) + recipient32 = self._recipient_bytes32(route, recipient) + owner = self._owner(sender) + q = self._quote_hyperlane(route, recipient32, atomic, owner) + destination = self.registry.asset(route.destination_asset_id) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner) + approval_required = None if q.allowance_atomic is None else q.allowance_atomic < q.token_amount_atomic + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=plan, fees=(self._native_fee(q.native_fee_atomic),), + amount_out=format_decimal_amount(atomic, destination.decimals), + recipient_bytes32=recipient32, native_value_atomic=q.native_value_atomic, + native_fee_atomic=q.native_fee_atomic, approval_required=approval_required) + __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index d9aae648..cc839abf 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -245,3 +245,17 @@ def _call(self, call: dict) -> str: def fake_web3(**config: Any) -> Web3: """A real ``Web3`` over ``FakeRpcProvider``; reach the state through ``w3.provider``.""" return Web3(FakeRpcProvider(**config)) + + +def make_bridge(*, ethereum: Any = None, **aleo_kwargs: Any) -> Any: + """A ``Bridge`` over a fresh mainnet ``FakeAleo``, wired with *ethereum* so ``bridge.eth`` works. + + Kept here (rather than in ``tests/conftest.py``) so ``eth.py`` tests can import one fixture + factory alongside ``fake_web3`` without pulling in pytest fixtures. + """ + from aleo_bridge import Bridge + + from tests.conftest import FakeAleo, default_mappings + + aleo_kwargs.setdefault("mappings", default_mappings()) + return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum) diff --git a/bridge-sdk/tests/test_eth_hyperlane_quote.py b/bridge-sdk/tests/test_eth_hyperlane_quote.py new file mode 100644 index 00000000..1fe92948 --- /dev/null +++ b/bridge-sdk/tests/test_eth_hyperlane_quote.py @@ -0,0 +1,107 @@ +import pytest +from eth_account import Account + +from aleo_bridge.encoding import aleo_address_to_bytes32, bytes32_to_aleo_address +from aleo_bridge.errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, InvalidAmountError, + InvalidRecipientError, RouteUnavailableError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import EvmHyperlaneQuote +from tests.fakes.fake_web3 import ZERO_ADDRESS, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO_BYTES32 = "b102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +VEIL_RECIPIENT_BYTES32 = "20e3629764d5338f74bee96675801b1fb29d1fc68b177668f9175708bef84311" +ETH_ROUTER = "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +USDT, USDT_ROUTER = "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31" + + +def eth_module(*, signed=True, **config): + w3 = fake_web3(**config) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(ethereum=conn).eth, w3 + + +def test_native_eth_quote_splits_fee_from_value(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 69_000_000_000_101)]}) + q = eth.quote_transfer_remote("ethereum/eth", ALEO, amount_atomic=100) + assert isinstance(q, EvmHyperlaneQuote) and q.kind == "evm-hyperlane" + assert q.plan.route_id == "hyperlane:ethereum/eth->aleo/eth" and q.plan.amount == "0.0000000000000001" + assert q.plan.sender == ACCT.address and q.amount_out == "0.0000000000000001" + assert q.native_value_atomic == 69_000_000_000_101 and q.native_fee_atomic == 69_000_000_000_001 + assert q.approval_required is None + assert q.recipient_bytes32.hex() == ALEO_BYTES32 + assert len(q.fees) == 1 and q.fees[0].kind == "network" and q.fees[0].chain_id == "ethereum" + assert q.fees[0].asset_id == "ethereum/eth" and q.fees[0].amount == "0.000069000000000001" and q.fees[0].estimated + + +def test_recipient_bytes32_matches_veil_vector(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + recipient = bytes32_to_aleo_address(bytes.fromhex(VEIL_RECIPIENT_BYTES32)) + q = eth.quote_transfer_remote("eth", recipient, amount_atomic=1) + assert q.recipient_bytes32 == bytes.fromhex(VEIL_RECIPIENT_BYTES32) + assert aleo_address_to_bytes32(recipient) == q.recipient_bytes32 + assert q.plan.recipient == recipient + + +def test_collateral_wbtc_quote_reads_allowance(): + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 0}) + q = eth.quote_transfer_remote("ethereum/wbtc", ALEO, amount="0.001") + assert q.plan.amount_atomic == 100_000 and q.native_value_atomic == 50_000 and q.native_fee_atomic == 50_000 + assert q.approval_required is True + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 100_000}) + assert eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000).approval_required is False + + +def test_read_only_connection_quotes_with_explicit_or_no_sender(): + eth, _ = eth_module(signed=False, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}, + allowances={(USDT, ACCT.address, USDT_ROUTER): 1}) + q = eth.quote_transfer_remote("usdt", ALEO, amount="1") + assert q.approval_required is None and q.plan.sender is None + q = eth.quote_transfer_remote("usdt", ALEO, amount="1", sender=ACCT.address) + assert q.approval_required is True and q.plan.sender == ACCT.address + + +def test_quote_must_cover_amount(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 50)]}) + with pytest.raises(BridgeError, match="Native Hyperlane quote does not cover"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=100) + eth, _ = eth_module(quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 99_999)]}) + with pytest.raises(BridgeError, match="Collateral Hyperlane quote does not cover"): + eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000) + + +def test_wrong_chain_is_refused_before_any_contract_read(): + eth, w3 = eth_module(chain_id=11155111, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + with pytest.raises(ChainMismatchError, match="expected 1"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1) + assert "eth_call" not in w3.provider.methods + + +def test_unavailable_unknown_and_explicit_routes(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(RouteUnavailableError): + eth.quote_transfer_remote("ethereum/usad", ALEO, amount_atomic=1) + with pytest.raises(BridgeError): + eth.quote_transfer_remote("ethereum/doge", ALEO, amount_atomic=1) + with pytest.raises(BridgeError, match="not a Hyperlane route"): + eth.quote_transfer_remote("ethereum/usdc", ALEO, amount_atomic=1) + route = DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth") + assert eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route).plan.route_id == route.id + + +def test_amount_and_recipient_validation(): + eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidAmountError): + eth.quote_transfer_remote("eth", ALEO, amount="1", amount_atomic=1) + with pytest.raises(InvalidAmountError): + eth.quote_transfer_remote("eth", ALEO) + with pytest.raises(InvalidRecipientError): + eth.quote_transfer_remote("eth", "aleo1notanaddress", amount_atomic=1) + with pytest.raises(InvalidRecipientError): + eth.quote_transfer_remote("eth", "0x0000000000000000000000000000000000000001", amount_atomic=1) From 748f75bebb1e8250158c31167567161aa6555867 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:05:58 -0400 Subject: [PATCH 31/94] feat(bridge): eth.transfer_remote with approval reset, DispatchId message id and pending timeouts --- bridge-sdk/python/aleo_bridge/eth.py | 84 +++++++++++- .../tests/test_eth_hyperlane_execute.py | 129 ++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/tests/test_eth_hyperlane_execute.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index da3da84e..b7d5c4dd 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -12,12 +12,13 @@ from typing import Any, Mapping from . import encoding -from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, WARP_ROUTE_ABI, ZERO_ADDRESS +from ._calls import EvmCall, EvmOutcome, EvmStep +from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, ZERO_ADDRESS from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError) from .registry import Asset, Chain, Registry, Route -from .types import EvmHyperlaneQuote, Fee, Plan, Step +from .types import DispatchReceipt, EvmHyperlaneQuote, Fee, Plan, Receipt, Status, Step from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -369,5 +370,84 @@ def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = Non recipient_bytes32=recipient32, native_value_atomic=q.native_value_atomic, native_fee_atomic=q.native_fee_atomic, approval_required=approval_required) + # -- Hyperlane execute -------------------------------------------------------------------- + + def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: + """Hyperlane Mailbox ``DispatchId(bytes32 indexed messageId)`` from a confirmed receipt; ``None`` if absent.""" + from web3.logs import DISCARD + + mailbox = self._contract(str(route.metadata["mailboxAddress"]), MAILBOX_ABI) + events = mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) + if not events: + return None + return _web3().Web3.to_hex(events[-1]["args"]["messageId"]) + + @staticmethod + def _hyperlane_protocol_state(route: Route, *, recipient_bytes32: bytes, destination_domain: int, + native_value_atomic: int, amount_atomic: int, approval_tx_ids: list[str], + sender: str | None, message_id: str | None = None) -> dict[str, Any]: + state: dict[str, Any] = { + "routeId": route.id, "approvalTxIds": list(approval_tx_ids), "sourceSender": sender, + "recipientBytes32": "0x" + recipient_bytes32.hex(), "destinationDomain": destination_domain, + "nativeValueAtomic": str(native_value_atomic), "amountAtomic": str(amount_atomic), + } + if message_id is not None: + state["messageId"] = message_id + return state + + def _hyperlane_result(self, route: Route, q: "_HyperlaneQuote", outcome: EvmOutcome) -> DispatchReceipt: + approvals = list(outcome.approval_tx_ids) + if outcome.status == "CONFIRMED": + message_id = self._message_id_from_receipt(route, outcome.receipt) + status, rid = Status.DELIVERY_PENDING, message_id or outcome.source_tx_id + else: + message_id, status = None, Status(outcome.status) + rid = outcome.source_tx_id or approvals[-1] + state = self._hyperlane_protocol_state( + route, recipient_bytes32=q.recipient_bytes32, destination_domain=q.destination_domain, + native_value_atomic=q.native_value_atomic, amount_atomic=q.amount_atomic, + approval_tx_ids=approvals, sender=outcome.sender, message_id=message_id) + receipt = Receipt(id=rid, protocol="hyperlane", status=status, source_tx_id=outcome.source_tx_id, protocol_state=state) + return DispatchReceipt(transaction_id=outcome.source_tx_id or approvals[-1], route_id=route.id, + message_id=message_id, amount_atomic=q.amount_atomic, receipt=receipt) + + def transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, + amount_atomic: int | None = None) -> EvmCall[DispatchReceipt]: + """Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + + Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the + quoted token amount only when the allowance is short (USDT: a non-zero allowance is + reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes + send the fee only. Each hash is checkpointed before polling; a timeout returns a + pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + """ + route = self._hyperlane_route(self._asset(asset)) + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + recipient32 = self._recipient_bytes32(route, recipient) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender) + latest: dict[str, _HyperlaneQuote] = {} + + def steps(owner: str) -> list[EvmStep]: + q = self._quote_hyperlane(route, recipient32, atomic, owner) # last responsible moment + latest["q"] = q + out: list[EvmStep] = [] + if q.router_type == "collateral" and (q.allowance_atomic or 0) < q.token_amount_atomic: + token = self._erc20(q.token) + if (q.allowance_atomic or 0) > 0 and q.requires_approval_reset: + out.append(EvmStep("approve", q.token, token.encode_abi("approve", args=[q.router, 0]))) + out.append(EvmStep("approve", q.token, token.encode_abi("approve", args=[q.router, q.token_amount_atomic]))) + warp = self._contract(q.router, WARP_ROUTE_ABI) + out.append(EvmStep("main", q.router, + warp.encode_abi("transferRemote", args=[q.destination_domain, recipient32, atomic]), + q.native_value_atomic)) + return out + + def finish(outcome: EvmOutcome) -> DispatchReceipt: + return self._hyperlane_result(route, latest["q"], outcome) + + return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, + store=self.bridge.checkpoints) + __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py new file mode 100644 index 00000000..9b974623 --- /dev/null +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -0,0 +1,129 @@ +import pytest +from eth_account import Account +from eth_utils import keccak +from web3 import Web3 + +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import DispatchReceipt, Status +from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, fake_web3, make_bridge, tx_hash_for + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO_BYTES32 = "0xb102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" +ETH_ROUTER = "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +USDT, USDT_ROUTER = "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0x3C2064D78e4578E8F936E3db42aEF044E33FBF31" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +MESSAGE_ID = bytes.fromhex("ab" * 32) +APPROVE = keccak(text="approve(address,uint256)")[:4].hex() +TRANSFER_REMOTE = keccak(text="transferRemote(uint32,bytes32,uint256)")[:4].hex() + + +def setup(router, *, with_dispatch_log=True, **config): + w3 = fake_web3(**config) + if with_dispatch_log: + w3.provider.receipt_logs = lambda tx: ( + [dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=tx["hash"])] if tx["to"] == Web3.to_checksum_address(router) else []) + bridge = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)) + return bridge.eth, w3 + + +def test_native_eth_dispatch_is_one_transaction_with_value(): + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 69_000_000_000_101)]}) + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert isinstance(result, DispatchReceipt) + assert len(w3.provider.sent) == 1 and w3.provider.sent[0]["value"] == 0x3EC1507D5065 + assert w3.provider.sent[0]["to"] == Web3.to_checksum_address(ETH_ROUTER) + assert w3.provider.sent[0]["data"][2:10] == TRANSFER_REMOTE + receipt = result.receipt + assert receipt.status == Status.DELIVERY_PENDING and receipt.protocol == "hyperlane" + assert result.message_id == Web3.to_hex(MESSAGE_ID) and receipt.id == result.message_id + assert receipt.source_tx_id == tx_hash_for(1) == result.transaction_id and result.amount_atomic == 100 + assert receipt.protocol_state == { + "routeId": "hyperlane:ethereum/eth->aleo/eth", "approvalTxIds": [], "sourceSender": ACCT.address, + "recipientBytes32": ALEO_BYTES32, "destinationDomain": 1634493807, + "nativeValueAtomic": "69000000000101", "amountAtomic": "100", "messageId": Web3.to_hex(MESSAGE_ID), + } + + +def test_wbtc_approves_exact_token_amount_then_dispatches_with_fee_value(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + result = eth.transfer_remote("ethereum/wbtc", ALEO, amount="0.001").send(poll_seconds=0.001) + sent = w3.provider.sent + assert [t["to"] for t in sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(WBTC_ROUTER)] + assert sent[0]["data"][2:].lower() == APPROVE + WBTC_ROUTER[2:].lower().rjust(64, "0") + format(100_000, "064x") + assert sent[0]["value"] == 0 and sent[1]["value"] == 0xC350 + assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and result.receipt.source_tx_id == tx_hash_for(2) + + +def test_wbtc_sufficient_allowance_skips_approval(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}, + allowances={(WBTC, ACCT.address, WBTC_ROUTER): 100_000}) + result = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 and result.receipt.protocol_state["approvalTxIds"] == [] + + +def test_usdt_resets_non_zero_allowance_first(): + eth, w3 = setup(USDT_ROUTER, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}, + allowances={(USDT, ACCT.address, USDT_ROUTER): 1}) + result = eth.transfer_remote("usdt", ALEO, amount="1").send(poll_seconds=0.001) + sent = w3.provider.sent + assert len(sent) == 3 + assert sent[0]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + "0" * 64 + assert sent[1]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + format(1_000_000, "064x") + assert sent[2]["data"][2:10] == TRANSFER_REMOTE + assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1), tx_hash_for(2)] + + +def test_usdt_zero_allowance_needs_no_reset(): + eth, w3 = setup(USDT_ROUTER, quotes={USDT_ROUTER: [(ZERO_ADDRESS, 50_000), (USDT, 1_000_000)]}) + eth.transfer_remote("usdt", ALEO, amount="1").send(poll_seconds=0.001) + assert len(w3.provider.sent) == 2 + + +def test_approval_timeout_is_pending_and_checkpointed_before_polling(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + w3.provider.pending.add(tx_hash_for(1)) + seen = [] + result = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).send( + timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.id == tx_hash_for(1) and result.message_id is None and len(w3.provider.sent) == 1 + assert [cp.source for cp in seen] == [{"approvalTransactionIds": [tx_hash_for(1)]}] + assert seen[0].intent == {"source": {"chain": "ethereum", "asset": "wbtc"}, "destination": {"chain": "aleo", "asset": "wbtc"}, + "bridgeProtocol": "hyperlane", "amount": "0.001", "recipient": ALEO, + "sender": ACCT.address, "mintMode": "public"} + + +def test_dispatch_timeout_is_source_confirming_with_hash(): + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.pending.add(tx_hash_for(1)) + seen = [] + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send( + timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) + assert result.receipt.id == tx_hash_for(1) and "messageId" not in result.receipt.protocol_state + assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1)}] + + +def test_missing_dispatch_id_log_keeps_tx_hash_as_id(): + eth, _ = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.receipt.status == Status.DELIVERY_PENDING and result.message_id is None + assert result.receipt.id == tx_hash_for(1) and "messageId" not in result.receipt.protocol_state + + +def test_build_lists_approval_then_dispatch_without_sending(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + txs = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(WBTC_ROUTER)] + assert [t["value"] for t in txs] == [0, 50_000] and w3.provider.sent == [] + + +def test_read_only_connection_cannot_transfer(): + w3 = fake_web3(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + eth = make_bridge(ethereum=Ethereum(w3=w3)).eth + with pytest.raises(ConfigurationError, match="read-only"): + eth.transfer_remote("eth", ALEO, amount_atomic=100) From 0f8acfce7cd15e0ccae62ea527828e4e67adbd34 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:11:00 -0400 Subject: [PATCH 32/94] feat(bridge): eth.quote_deposit_usdc with hook data, wrapper-program private recipient and minimum --- bridge-sdk/python/aleo_bridge/eth.py | 94 ++++++++++++++++++++- bridge-sdk/tests/fakes/fake_web3.py | 9 +- bridge-sdk/tests/test_eth_xreserve_quote.py | 88 +++++++++++++++++++ 3 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 bridge-sdk/tests/test_eth_xreserve_quote.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index b7d5c4dd..3fcc7efa 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -14,11 +14,11 @@ from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, ZERO_ADDRESS -from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InvalidAmountError, - InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, - RouteUnavailableError) +from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, + InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, + RouteNotFoundError, RouteUnavailableError) from .registry import Asset, Chain, Registry, Route -from .types import DispatchReceipt, EvmHyperlaneQuote, Fee, Plan, Receipt, Status, Step +from .types import DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, Receipt, Status, Step from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -214,6 +214,26 @@ class _HyperlaneQuote: requires_approval_reset: bool +@dataclass(frozen=True) +class _XReserveQuote: + """Contract-level facts behind an ``EvmXReserveQuote``; also rebuilt from receipts during status/recovery.""" + + xreserve_contract: str + token: str + source_chain_id: int + source_domain: int + remote_domain: int + remote_token_bytes32: bytes + remote_recipient_bytes32: bytes + amount_atomic: int + max_fee_atomic: int + hook_data: bytes + balance_atomic: int + allowance_atomic: int + bridge_program: str + wrapper_program: str + + class EthModule: """``bridge.eth`` — Ethereum-origin Hyperlane and xReserve actions (reads return values, writes return ``EvmCall``).""" @@ -370,6 +390,72 @@ def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = Non recipient_bytes32=recipient32, native_value_atomic=q.native_value_atomic, native_fee_atomic=q.native_fee_atomic, approval_required=approval_required) + # -- xReserve quote ------------------------------------------------------------------------- + + def _xreserve_recipient_bytes32(self, route: Route, recipient: str, mint_mode: str) -> bytes: + """Invariant 7: private deposits are addressed to the wrapper program's account address.""" + self._recipient_bytes32(route, recipient) # validates the intended recipient + if mint_mode == "private": + wrapper = str(route.metadata["wrapperProgram"]) + return encoding.aleo_address_to_bytes32(encoding.aleo_program_address(wrapper, self.network)) + return encoding.aleo_address_to_bytes32(recipient) + + def _quote_xreserve(self, route: Route, recipient: str, amount_atomic: int, owner: str | None, + mint_mode: str, secret_nonce: str) -> _XReserveQuote: + """Brief §3.2 quote: chain assert → minimum → hook data → wire recipient → balanceOf/allowance.""" + if mint_mode not in ("public", "record", "private"): + raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") + self.assert_chain(route) + Web3 = _web3().Web3 + meta = route.metadata + minimum = int(str(meta["minimumAmountAtomic"])) + if amount_atomic < minimum: + raise InvalidAmountError(f"xReserve minimum deposit is {minimum} atomic units") + if owner is None: + raise ConfigurationError("xReserve quotes read the depositor's balance: pass sender= or configure a signer") + source = self.registry.asset(route.source_asset_id) + if source.locator is None or source.locator.kind != "evm-contract": + raise RouteUnavailableError(f"xReserve source token contract is missing: {route.id}") + token = Web3.to_checksum_address(source.locator.value) + xreserve = Web3.to_checksum_address(str(meta["xReserveContract"])) + hook_data = encoding.xreserve_hook_data(mint_mode, recipient, self.network, secret_nonce) + remote_recipient = self._xreserve_recipient_bytes32(route, recipient, mint_mode) + erc20 = self._erc20(token) + balance = int(erc20.functions.balanceOf(owner).call()) + allowance = int(erc20.functions.allowance(owner, xreserve).call()) + if balance < amount_atomic: + raise InsufficientBalanceError(f"Insufficient {source.symbol} balance: {balance} < {amount_atomic} atomic units") + return _XReserveQuote( + xreserve_contract=xreserve, token=token, source_chain_id=int(meta["sourceChainId"]), + source_domain=int(meta["sourceDomain"]), remote_domain=int(meta["remoteDomain"]), + remote_token_bytes32=bytes.fromhex(str(meta["remoteTokenBytes32"])[2:]), + remote_recipient_bytes32=remote_recipient, amount_atomic=amount_atomic, + max_fee_atomic=int(str(meta["maxFeeAtomic"])), hook_data=hook_data, + balance_atomic=balance, allowance_atomic=allowance, + bridge_program=str(meta["bridgeProgram"]), wrapper_program=str(meta["wrapperProgram"])) + + def quote_deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str = "public", secret_nonce: str = "0scalar", + sender: str | None = None) -> EvmXReserveQuote: + """Quote a USDC → USDCx xReserve deposit without signing. + + Checks the 2 USDC minimum, derives the 65-byte hook (``public``/``record``/``private``; + private commits ``recipient`` with ``secret_nonce`` via BHP256) and the wire recipient + (the shielded wrapper program's address for ``private``), and reads the depositor's + USDC balance and xReserve allowance. ``secret_nonce`` is never stored by the SDK. + """ + route = self._xreserve_route() + atomic = self._amount_atomic(route, amount, amount_atomic) + owner = self._owner(sender) + q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) + destination = self.registry.asset(route.destination_asset_id) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner, mint_mode=mint_mode) + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), + amount_out=format_decimal_amount(atomic, destination.decimals), + hook_data=q.hook_data, remote_recipient_bytes32=q.remote_recipient_bytes32, + balance_atomic=q.balance_atomic, allowance_atomic=q.allowance_atomic, + approval_required=q.allowance_atomic < atomic, max_fee_atomic=q.max_fee_atomic) + # -- Hyperlane execute -------------------------------------------------------------------- def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index cc839abf..0da3a413 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -247,8 +247,9 @@ def fake_web3(**config: Any) -> Web3: return Web3(FakeRpcProvider(**config)) -def make_bridge(*, ethereum: Any = None, **aleo_kwargs: Any) -> Any: - """A ``Bridge`` over a fresh mainnet ``FakeAleo``, wired with *ethereum* so ``bridge.eth`` works. +def make_bridge(*, ethereum: Any = None, environment: str | None = None, **aleo_kwargs: Any) -> Any: + """A ``Bridge`` over a fresh ``FakeAleo`` (mainnet unless *environment* says otherwise), wired + with *ethereum* so ``bridge.eth`` works. Kept here (rather than in ``tests/conftest.py``) so ``eth.py`` tests can import one fixture factory alongside ``fake_web3`` without pulling in pytest fixtures. @@ -258,4 +259,6 @@ def make_bridge(*, ethereum: Any = None, **aleo_kwargs: Any) -> Any: from tests.conftest import FakeAleo, default_mappings aleo_kwargs.setdefault("mappings", default_mappings()) - return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum) + if environment is not None: + aleo_kwargs.setdefault("network_name", environment) + return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum, environment=environment) diff --git a/bridge-sdk/tests/test_eth_xreserve_quote.py b/bridge-sdk/tests/test_eth_xreserve_quote.py new file mode 100644 index 00000000..e869c3a9 --- /dev/null +++ b/bridge-sdk/tests/test_eth_xreserve_quote.py @@ -0,0 +1,88 @@ +import pytest +from eth_account import Account + +from aleo_bridge.encoding import aleo_address_to_bytes32, aleo_program_address, xreserve_hook_data +from aleo_bridge.errors import (BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, + InvalidAmountError) +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import EvmXReserveQuote +from tests.fakes.fake_web3 import fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" +SEPOLIA_XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442" +MAINNET_USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +MAINNET_XRESERVE = "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" + + +def sepolia(*, signed=True, balance=3_000_000, allowance=0, chain_id=11155111): + w3 = fake_web3(chain_id=chain_id, token_balances={(SEPOLIA_USDC, ACCT.address): balance}, + allowances={(SEPOLIA_USDC, ACCT.address, SEPOLIA_XRESERVE): allowance}) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(environment="testnet", ethereum=conn).eth, w3 + + +def test_record_mode_quote(): + eth, _ = sepolia() + q = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="record") + assert isinstance(q, EvmXReserveQuote) and q.kind == "evm-xreserve" + assert q.plan.route_id == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and q.plan.mint_mode == "record" + assert q.plan.amount == "2" and q.plan.amount_atomic == 2_000_000 and q.plan.sender == ACCT.address + assert q.amount_out == "2" and q.fees == () + assert q.hook_data == b"\x01" + bytes(64) + assert q.remote_recipient_bytes32 == aleo_address_to_bytes32(ALEO) + assert q.balance_atomic == 3_000_000 and q.allowance_atomic == 0 and q.approval_required is True + assert q.max_fee_atomic == 100_000 + + +def test_public_mode_is_default_and_allowance_can_cover(): + eth, _ = sepolia(allowance=5_000_000) + q = eth.quote_deposit_usdc(ALEO, amount_atomic=2_000_000) + assert q.plan.mint_mode == "public" and q.hook_data == bytes(65) and q.approval_required is False + + +def test_private_mode_targets_wrapper_program_and_commits_recipient(): + eth, _ = sepolia() + q = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="private", secret_nonce="7scalar") + wrapper = aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet") + assert q.remote_recipient_bytes32 == aleo_address_to_bytes32(wrapper) + assert q.hook_data[0] == 2 and len(q.hook_data) == 65 and q.hook_data[1:33] != bytes(32) + assert q.hook_data == xreserve_hook_data("private", ALEO, "testnet", "7scalar") + assert q.hook_data != eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="private").hook_data + assert q.plan.recipient == ALEO # the plan keeps the intended recipient, not the wrapper + + +def test_minimum_amount_and_balance_are_enforced(): + eth, _ = sepolia() + with pytest.raises(InvalidAmountError, match="minimum deposit is 2000000"): + eth.quote_deposit_usdc(ALEO, amount_atomic=1_999_999) + with pytest.raises(InsufficientBalanceError, match="USDC"): + eth.quote_deposit_usdc(ALEO, amount_atomic=3_000_001) + + +def test_wrong_chain_and_bad_mint_mode(): + eth, w3 = sepolia(chain_id=1) + with pytest.raises(ChainMismatchError, match="expected 11155111"): + eth.quote_deposit_usdc(ALEO, amount="2") + assert "eth_call" not in w3.provider.methods + eth, _ = sepolia() + with pytest.raises(BridgeError, match="mint_mode"): + eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="shielded") + + +def test_read_only_needs_explicit_sender(): + eth, _ = sepolia(signed=False) + with pytest.raises(ConfigurationError, match="sender"): + eth.quote_deposit_usdc(ALEO, amount="2") + q = eth.quote_deposit_usdc(ALEO, amount="2", sender=ACCT.address) + assert q.balance_atomic == 3_000_000 and q.plan.sender == ACCT.address + + +def test_mainnet_environment_selects_ethereum_route(): + w3 = fake_web3(chain_id=1, token_balances={(MAINNET_USDC, ACCT.address): 2_000_000}, + allowances={(MAINNET_USDC, ACCT.address, MAINNET_XRESERVE): 0}) + eth = make_bridge(environment="mainnet", ethereum=Ethereum(w3=w3, private_key=KEY)).eth + q = eth.quote_deposit_usdc(ALEO, amount="2") + assert q.plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and q.approval_required is True From b67291992ff0915e5986b4df7cc6e0a3dd2d3d1e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:16:26 -0400 Subject: [PATCH 33/94] fix(bridge-sdk): validate Hyperlane route metadata before contract calls; first DispatchId wins --- bridge-sdk/python/aleo_bridge/eth.py | 106 ++++++++++++++---- .../tests/test_eth_hyperlane_execute.py | 32 +++++- bridge-sdk/tests/test_eth_hyperlane_quote.py | 43 ++++++- 3 files changed, 159 insertions(+), 22 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 3fcc7efa..fd316c40 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -197,6 +197,30 @@ def _plan_for(registry: Registry, route: Route, *, amount_atomic: int, recipient recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) +_REGISTRY_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + + +@dataclass(frozen=True) +class _HyperlaneRouteMetadata: + """Validated Hyperlane route metadata (mirrors veil ``protocols/hyperlane/evm.ts`` ``routeMetadata``). + + Every address/domain that reaches a contract call is checked here first, so a corrupted or + malformed registry entry fails with ``ConfigurationError`` before any RPC read. + """ + + router: str + router_type: str # "native" | "collateral" + token: str | None # collateral ERC-20; None on native + mailbox: str + interchain_gas_paymaster: str + interchain_security_module: str + source_chain_id: int + destination_domain: int + destination_router: str + registry_commit: str + requires_approval_reset: bool + + @dataclass(frozen=True) class _HyperlaneQuote: """Router-level facts behind an ``EvmHyperlaneQuote`` (addresses never leave the module).""" @@ -339,32 +363,76 @@ def _native_fee(self, amount_wei: int) -> Fee: # -- Hyperlane quote ---------------------------------------------------------------------- + def _metadata_address(self, meta: Mapping[str, Any], key: str, route_id: str) -> str: + """Checksum a metadata address field; any malformed value is a ``ConfigurationError``, never + a raw ``ValueError``/``KeyError`` (a missing key reads as ``None`` via ``.get``, which also fails here).""" + value = meta.get(key) + try: + return _web3().Web3.to_checksum_address(str(value)) + except (ValueError, TypeError) as exc: + raise ConfigurationError(f"Hyperlane route metadata {key!r} is not a valid address ({route_id}): {value!r}") from exc + + def _hyperlane_metadata(self, route: Route) -> _HyperlaneRouteMetadata: + """Brief §3.1 route metadata validator (mirrors veil ``protocols/hyperlane/evm.ts`` ``routeMetadata``). + + Called by every path that is about to touch a Hyperlane contract (``_quote_hyperlane``, and + transitively ``transfer_remote``'s step builder through the ``_HyperlaneQuote`` it returns) so + no unvalidated address or domain from the registry ever reaches an RPC call. + """ + if route is None or route.protocol != "hyperlane" or route.availability != "active": + raise RouteUnavailableError(f"Hyperlane route is not executable: {getattr(route, 'id', route)!r}") + meta = route.metadata + router = self._metadata_address(meta, "routerAddress", route.id) + mailbox = self._metadata_address(meta, "mailboxAddress", route.id) + igp = self._metadata_address(meta, "interchainGasPaymaster", route.id) + ism = self._metadata_address(meta, "interchainSecurityModule", route.id) + source_chain_id = meta.get("sourceChainId") + if isinstance(source_chain_id, bool) or not isinstance(source_chain_id, int) or source_chain_id <= 0: + raise ConfigurationError( + f"Hyperlane route metadata sourceChainId must be a positive int ({route.id}): {source_chain_id!r}") + destination_domain = meta.get("destinationDomain") + if isinstance(destination_domain, bool) or not isinstance(destination_domain, int) \ + or not (0 <= destination_domain <= 2**32 - 1): + raise ConfigurationError( + f"Hyperlane route metadata destinationDomain must be a uint32 ({route.id}): {destination_domain!r}") + router_type = meta.get("routerType") + if router_type not in ("native", "collateral"): + raise ConfigurationError( + f"Hyperlane route metadata routerType must be native or collateral ({route.id}): {router_type!r}") + token = self._metadata_address(meta, "tokenAddress", route.id) if router_type == "collateral" else None + destination_router = meta.get("destinationRouter") + if not isinstance(destination_router, str) or not destination_router.strip(): + raise ConfigurationError(f"Hyperlane route metadata destinationRouter must be non-empty ({route.id})") + registry_commit = meta.get("registryCommit") + if not isinstance(registry_commit, str) or not _REGISTRY_COMMIT_RE.fullmatch(registry_commit): + raise ConfigurationError( + f"Hyperlane route metadata registryCommit must be 40 hex chars ({route.id}): {registry_commit!r}") + return _HyperlaneRouteMetadata( + router=router, router_type=router_type, token=token, mailbox=mailbox, + interchain_gas_paymaster=igp, interchain_security_module=ism, source_chain_id=source_chain_id, + destination_domain=destination_domain, destination_router=destination_router, + registry_commit=registry_commit, requires_approval_reset=meta.get("requiresApprovalReset") is True) + def _quote_hyperlane(self, route: Route, recipient_bytes32: bytes, amount_atomic: int, owner: str | None) -> _HyperlaneQuote: - """Brief §3.1: chain assert → quoteTransferRemote → native/collateral split → allowance.""" + """Brief §3.1: chain assert → metadata validation → quoteTransferRemote → native/collateral split → allowance.""" self.assert_chain(route) + meta = self._hyperlane_metadata(route) Web3 = _web3().Web3 - meta = route.metadata - router = Web3.to_checksum_address(str(meta["routerAddress"])) - router_type = str(meta["routerType"]) - destination_domain = int(meta["destinationDomain"]) - quotes = self._contract(router, WARP_ROUTE_ABI).functions.quoteTransferRemote( - destination_domain, recipient_bytes32, amount_atomic).call() + quotes = self._contract(meta.router, WARP_ROUTE_ABI).functions.quoteTransferRemote( + meta.destination_domain, recipient_bytes32, amount_atomic).call() native_value = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == ZERO_ADDRESS) - if router_type == "native": + if meta.router_type == "native": if native_value < amount_atomic: raise BridgeError("Native Hyperlane quote does not cover the transfer amount") - return _HyperlaneQuote(router, "native", None, destination_domain, recipient_bytes32, amount_atomic, - native_value, native_value - amount_atomic, 0, None, False) - if router_type != "collateral": - raise RouteUnavailableError(f"Hyperlane route has an invalid routerType {router_type!r}: {route.id}") - token = Web3.to_checksum_address(str(meta["tokenAddress"])) - token_amount = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == token) + return _HyperlaneQuote(meta.router, "native", None, meta.destination_domain, recipient_bytes32, + amount_atomic, native_value, native_value - amount_atomic, 0, None, False) + token_amount = sum(int(q[1]) for q in quotes if Web3.to_checksum_address(q[0]) == meta.token) if token_amount < amount_atomic: raise BridgeError("Collateral Hyperlane quote does not cover the transfer amount") - allowance = int(self._erc20(token).functions.allowance(owner, router).call()) if owner else None - return _HyperlaneQuote(router, "collateral", token, destination_domain, recipient_bytes32, amount_atomic, - native_value, native_value, token_amount, allowance, - meta.get("requiresApprovalReset") is True) + allowance = int(self._erc20(meta.token).functions.allowance(owner, meta.router).call()) if owner else None + return _HyperlaneQuote(meta.router, "collateral", meta.token, meta.destination_domain, recipient_bytes32, + amount_atomic, native_value, native_value, token_amount, allowance, + meta.requires_approval_reset) def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, route: Route | None = None, sender: str | None = None) -> EvmHyperlaneQuote: @@ -466,7 +534,7 @@ def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: events = mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) if not events: return None - return _web3().Web3.to_hex(events[-1]["args"]["messageId"]) + return _web3().Web3.to_hex(events[0]["args"]["messageId"]) # veil messageIdFromReceipt: first match wins @staticmethod def _hyperlane_protocol_state(route: Route, *, recipient_bytes32: bytes, destination_domain: int, diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py index 9b974623..37392af5 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_execute.py +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -6,7 +6,7 @@ from aleo_bridge.errors import ConfigurationError from aleo_bridge.eth import Ethereum from aleo_bridge.types import DispatchReceipt, Status -from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, fake_web3, make_bridge, tx_hash_for +from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge, tx_hash_for KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) @@ -108,6 +108,36 @@ def test_dispatch_timeout_is_source_confirming_with_hash(): assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1)}] +def test_dispatch_id_survives_unrelated_log_before_it(): + """A log from an unrelated event (different address/topic, e.g. an ERC-20 Transfer) preceding the + Mailbox DispatchId log in the receipt must not prevent the message id from being decoded.""" + noise_topic = "0x" + keccak(text="Transfer(address,address,uint256)").hex() + + def logs_with_noise_first(tx): + noise = event_log(WBTC, [noise_topic], "0x", log_index=1, tx_hash=tx["hash"]) + dispatch = dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=tx["hash"], log_index=2) + return [noise, dispatch] if tx["to"] == Web3.to_checksum_address(ETH_ROUTER) else [] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = logs_with_noise_first + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(MESSAGE_ID) + + +def test_first_dispatch_id_wins_when_receipt_has_two(): + """veil's ``messageIdFromReceipt`` returns the FIRST matching DispatchId event, not the last.""" + first_id, second_id = bytes.fromhex("11" * 32), bytes.fromhex("22" * 32) + + def two_dispatch_logs(tx): + return [dispatch_id_log(MAILBOX, first_id, tx_hash=tx["hash"], log_index=1), + dispatch_id_log(MAILBOX, second_id, tx_hash=tx["hash"], log_index=2)] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = two_dispatch_logs + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(first_id) + + def test_missing_dispatch_id_log_keeps_tx_hash_as_id(): eth, _ = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) diff --git a/bridge-sdk/tests/test_eth_hyperlane_quote.py b/bridge-sdk/tests/test_eth_hyperlane_quote.py index 1fe92948..0277878a 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_quote.py +++ b/bridge-sdk/tests/test_eth_hyperlane_quote.py @@ -1,9 +1,11 @@ +import dataclasses + import pytest from eth_account import Account from aleo_bridge.encoding import aleo_address_to_bytes32, bytes32_to_aleo_address -from aleo_bridge.errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, InvalidAmountError, - InvalidRecipientError, RouteUnavailableError) +from aleo_bridge.errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, + InvalidAmountError, InvalidRecipientError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import EvmHyperlaneQuote @@ -95,6 +97,43 @@ def test_unavailable_unknown_and_explicit_routes(): assert eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route).plan.route_id == route.id +def _corrupted_eth_route(**overrides): + route = DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth") + return dataclasses.replace(route, metadata={**route.metadata, **overrides}) + + +def test_corrupted_router_address_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(routerAddress="not-an-address") + with pytest.raises(ConfigurationError, match="routerAddress"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bad_router_type_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(routerType="burn") + with pytest.raises(ConfigurationError, match="routerType"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_out_of_range_destination_domain_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(destinationDomain=2**32) + with pytest.raises(ConfigurationError, match="destinationDomain"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bad_registry_commit_is_refused_before_any_contract_read(): + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 10**15)]}) + route = _corrupted_eth_route(registryCommit="not-hex") + with pytest.raises(ConfigurationError, match="registryCommit"): + eth.quote_transfer_remote("eth", ALEO, amount_atomic=1, route=route) + assert "eth_call" not in w3.provider.methods + + def test_amount_and_recipient_validation(): eth, _ = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) with pytest.raises(InvalidAmountError): From c8687fb4af6f33d120dd686f5514089904cc85b3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:22:27 -0400 Subject: [PATCH 34/94] fix(bridge-sdk): validate xReserve route metadata before contract calls --- bridge-sdk/python/aleo_bridge/eth.py | 140 +++++++++++++++++--- bridge-sdk/tests/test_eth_xreserve_quote.py | 56 ++++++++ 2 files changed, 175 insertions(+), 21 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index fd316c40..6818e9cd 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -238,6 +238,32 @@ class _HyperlaneQuote: requires_approval_reset: bool +_DIGIT_STRING_RE = re.compile(r"^[0-9]+$") + + +@dataclass(frozen=True) +class _XReserveRouteMetadata: + """Validated xReserve route metadata (mirrors veil ``protocols/xreserve/evmToAleo.ts`` ``routeMetadata``). + + Every address, domain, program name and fee that reaches a contract call or an Aleo encoder is + checked here first, so a corrupted or malformed registry entry fails with ``ConfigurationError`` + before any RPC read. + """ + + xreserve_contract: str + source_chain_id: int + source_domain: int + remote_domain: int + remote_token_bytes32: bytes + minimum_amount_atomic: int + withdrawal_fee_atomic: int + max_fee_atomic: int + bridge_program: str + wrapper_program: str + remote_token: str + attestation_base_url: str + + @dataclass(frozen=True) class _XReserveQuote: """Contract-level facts behind an ``EvmXReserveQuote``; also rebuilt from receipts during status/recovery.""" @@ -460,51 +486,123 @@ def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = Non # -- xReserve quote ------------------------------------------------------------------------- - def _xreserve_recipient_bytes32(self, route: Route, recipient: str, mint_mode: str) -> bytes: + def _metadata_digits(self, meta: Mapping[str, Any], key: str, route_id: str) -> int: + """A digit-string atomic-amount metadata field as ``int``; never bool/float/junk.""" + value = meta.get(key) + if not isinstance(value, str) or not _DIGIT_STRING_RE.fullmatch(value): + raise ConfigurationError(f"xReserve route metadata {key!r} must be a digit string ({route_id}): {value!r}") + return int(value) + + def _metadata_aleo_program(self, meta: Mapping[str, Any], key: str, route_id: str) -> str: + value = meta.get(key) + if not isinstance(value, str) or not value.endswith(".aleo") or value == ".aleo": + raise ConfigurationError(f"xReserve route metadata {key!r} must be an .aleo program name ({route_id}): {value!r}") + return value + + def _xreserve_metadata(self, route: Route) -> _XReserveRouteMetadata: + """Brief §3.2 route metadata validator (mirrors veil ``protocols/xreserve/evmToAleo.ts`` ``routeMetadata``). + + Called by every path that is about to touch the xReserve contract or an Aleo program address + (``_quote_xreserve``, ``_xreserve_recipient_bytes32``, and Task 6's deposit execute) so no + unvalidated address, domain, fee, or program name from the registry ever reaches an RPC call. + """ + if route is None or route.protocol != "xreserve" or route.availability != "active": + raise RouteUnavailableError(f"xReserve route is not executable: {getattr(route, 'id', route)!r}") + source_chain = self.registry.chain(self.registry.asset(route.source_asset_id).chain_id) + if source_chain.family != "evm": + raise ConfigurationError(f"xReserve route source chain must be an EVM chain ({route.id}): {source_chain.id!r}") + expected_aleo_chain = "aleo-testnet" if self.network == "testnet" else "aleo" + destination_chain_id = self.registry.asset(route.destination_asset_id).chain_id + if destination_chain_id != expected_aleo_chain: + raise ConfigurationError( + f"xReserve route destination chain must be {expected_aleo_chain!r} ({route.id}): {destination_chain_id!r}") + meta = route.metadata + xreserve_contract = self._metadata_address(meta, "xReserveContract", route.id) + source_chain_id = meta.get("sourceChainId") + if isinstance(source_chain_id, bool) or not isinstance(source_chain_id, int) or source_chain_id <= 0: + raise ConfigurationError( + f"xReserve route metadata sourceChainId must be a positive int ({route.id}): {source_chain_id!r}") + source_domain = meta.get("sourceDomain") + if isinstance(source_domain, bool) or not isinstance(source_domain, int) or source_domain < 0: + raise ConfigurationError( + f"xReserve route metadata sourceDomain must be a non-negative int ({route.id}): {source_domain!r}") + remote_domain = meta.get("remoteDomain") + if isinstance(remote_domain, bool) or not isinstance(remote_domain, int) or remote_domain < 0: + raise ConfigurationError( + f"xReserve route metadata remoteDomain must be a non-negative int ({route.id}): {remote_domain!r}") + remote_token_bytes32_raw = meta.get("remoteTokenBytes32") + if not isinstance(remote_token_bytes32_raw, str): + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 must be a hex string ({route.id}): {remote_token_bytes32_raw!r}") + hex_text = remote_token_bytes32_raw[2:] if remote_token_bytes32_raw[:2] in ("0x", "0X") else remote_token_bytes32_raw + try: + remote_token_bytes32 = bytes.fromhex(hex_text) + except ValueError as exc: + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 is not valid hex ({route.id}): {remote_token_bytes32_raw!r}") from exc + if len(remote_token_bytes32) != 32: + raise ConfigurationError( + f"xReserve route metadata remoteTokenBytes32 must be exactly 32 bytes ({route.id}): {remote_token_bytes32_raw!r}") + minimum_amount_atomic = self._metadata_digits(meta, "minimumAmountAtomic", route.id) + withdrawal_fee_atomic = self._metadata_digits(meta, "withdrawalFeeAtomic", route.id) + max_fee_atomic = self._metadata_digits(meta, "maxFeeAtomic", route.id) + bridge_program = self._metadata_aleo_program(meta, "bridgeProgram", route.id) + wrapper_program = self._metadata_aleo_program(meta, "wrapperProgram", route.id) + remote_token = self._metadata_aleo_program(meta, "remoteToken", route.id) + attestation_base_url = meta.get("attestationBaseUrl") + if not isinstance(attestation_base_url, str) or not attestation_base_url.startswith("https://"): + raise ConfigurationError( + f"xReserve route metadata attestationBaseUrl must start with https:// ({route.id}): {attestation_base_url!r}") + return _XReserveRouteMetadata( + xreserve_contract=xreserve_contract, source_chain_id=source_chain_id, source_domain=source_domain, + remote_domain=remote_domain, remote_token_bytes32=remote_token_bytes32, + minimum_amount_atomic=minimum_amount_atomic, withdrawal_fee_atomic=withdrawal_fee_atomic, + max_fee_atomic=max_fee_atomic, bridge_program=bridge_program, wrapper_program=wrapper_program, + remote_token=remote_token, attestation_base_url=attestation_base_url) + + def _xreserve_recipient_bytes32(self, route: Route, meta: _XReserveRouteMetadata, recipient: str, + mint_mode: str) -> bytes: """Invariant 7: private deposits are addressed to the wrapper program's account address.""" self._recipient_bytes32(route, recipient) # validates the intended recipient if mint_mode == "private": - wrapper = str(route.metadata["wrapperProgram"]) - return encoding.aleo_address_to_bytes32(encoding.aleo_program_address(wrapper, self.network)) + return encoding.aleo_address_to_bytes32(encoding.aleo_program_address(meta.wrapper_program, self.network)) return encoding.aleo_address_to_bytes32(recipient) def _quote_xreserve(self, route: Route, recipient: str, amount_atomic: int, owner: str | None, mint_mode: str, secret_nonce: str) -> _XReserveQuote: - """Brief §3.2 quote: chain assert → minimum → hook data → wire recipient → balanceOf/allowance.""" + """Brief §3.2 quote: chain assert → metadata validation → minimum → hook data → wire recipient → + balanceOf/allowance.""" if mint_mode not in ("public", "record", "private"): raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") self.assert_chain(route) - Web3 = _web3().Web3 - meta = route.metadata - minimum = int(str(meta["minimumAmountAtomic"])) - if amount_atomic < minimum: - raise InvalidAmountError(f"xReserve minimum deposit is {minimum} atomic units") + meta = self._xreserve_metadata(route) + if amount_atomic < meta.minimum_amount_atomic: + raise InvalidAmountError(f"xReserve minimum deposit is {meta.minimum_amount_atomic} atomic units") if owner is None: raise ConfigurationError("xReserve quotes read the depositor's balance: pass sender= or configure a signer") source = self.registry.asset(route.source_asset_id) if source.locator is None or source.locator.kind != "evm-contract": raise RouteUnavailableError(f"xReserve source token contract is missing: {route.id}") - token = Web3.to_checksum_address(source.locator.value) - xreserve = Web3.to_checksum_address(str(meta["xReserveContract"])) + token = _web3().Web3.to_checksum_address(source.locator.value) hook_data = encoding.xreserve_hook_data(mint_mode, recipient, self.network, secret_nonce) - remote_recipient = self._xreserve_recipient_bytes32(route, recipient, mint_mode) + remote_recipient = self._xreserve_recipient_bytes32(route, meta, recipient, mint_mode) erc20 = self._erc20(token) balance = int(erc20.functions.balanceOf(owner).call()) - allowance = int(erc20.functions.allowance(owner, xreserve).call()) + allowance = int(erc20.functions.allowance(owner, meta.xreserve_contract).call()) if balance < amount_atomic: raise InsufficientBalanceError(f"Insufficient {source.symbol} balance: {balance} < {amount_atomic} atomic units") return _XReserveQuote( - xreserve_contract=xreserve, token=token, source_chain_id=int(meta["sourceChainId"]), - source_domain=int(meta["sourceDomain"]), remote_domain=int(meta["remoteDomain"]), - remote_token_bytes32=bytes.fromhex(str(meta["remoteTokenBytes32"])[2:]), + xreserve_contract=meta.xreserve_contract, token=token, source_chain_id=meta.source_chain_id, + source_domain=meta.source_domain, remote_domain=meta.remote_domain, + remote_token_bytes32=meta.remote_token_bytes32, remote_recipient_bytes32=remote_recipient, amount_atomic=amount_atomic, - max_fee_atomic=int(str(meta["maxFeeAtomic"])), hook_data=hook_data, + max_fee_atomic=meta.max_fee_atomic, hook_data=hook_data, balance_atomic=balance, allowance_atomic=allowance, - bridge_program=str(meta["bridgeProgram"]), wrapper_program=str(meta["wrapperProgram"])) + bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) def quote_deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, mint_mode: str = "public", secret_nonce: str = "0scalar", - sender: str | None = None) -> EvmXReserveQuote: + sender: str | None = None, route: Route | None = None) -> EvmXReserveQuote: """Quote a USDC → USDCx xReserve deposit without signing. Checks the 2 USDC minimum, derives the 65-byte hook (``public``/``record``/``private``; @@ -512,7 +610,7 @@ def quote_deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomi (the shielded wrapper program's address for ``private``), and reads the depositor's USDC balance and xReserve allowance. ``secret_nonce`` is never stored by the SDK. """ - route = self._xreserve_route() + route = route or self._xreserve_route() atomic = self._amount_atomic(route, amount, amount_atomic) owner = self._owner(sender) q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) @@ -530,7 +628,7 @@ def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: """Hyperlane Mailbox ``DispatchId(bytes32 indexed messageId)`` from a confirmed receipt; ``None`` if absent.""" from web3.logs import DISCARD - mailbox = self._contract(str(route.metadata["mailboxAddress"]), MAILBOX_ABI) + mailbox = self._contract(self._hyperlane_metadata(route).mailbox, MAILBOX_ABI) events = mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) if not events: return None diff --git a/bridge-sdk/tests/test_eth_xreserve_quote.py b/bridge-sdk/tests/test_eth_xreserve_quote.py index e869c3a9..adcf4061 100644 --- a/bridge-sdk/tests/test_eth_xreserve_quote.py +++ b/bridge-sdk/tests/test_eth_xreserve_quote.py @@ -1,3 +1,5 @@ +import dataclasses + import pytest from eth_account import Account @@ -5,6 +7,7 @@ from aleo_bridge.errors import (BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, InvalidAmountError) from aleo_bridge.eth import Ethereum +from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import EvmXReserveQuote from tests.fakes.fake_web3 import fake_web3, make_bridge @@ -86,3 +89,56 @@ def test_mainnet_environment_selects_ethereum_route(): eth = make_bridge(environment="mainnet", ethereum=Ethereum(w3=w3, private_key=KEY)).eth q = eth.quote_deposit_usdc(ALEO, amount="2") assert q.plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and q.approval_required is True + + +def _corrupted_xreserve_route(**overrides): + route = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") + return dataclasses.replace(route, metadata={**route.metadata, **overrides}) + + +def test_corrupted_xreserve_contract_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(xReserveContract="not-an-address") + with pytest.raises(ConfigurationError, match="xReserveContract"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_non_hex_remote_token_bytes32_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteTokenBytes32="not-hex") + with pytest.raises(ConfigurationError, match="remoteTokenBytes32"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_short_remote_token_bytes32_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteTokenBytes32="0x" + "ab" * 16) # 16 bytes, not 32 + with pytest.raises(ConfigurationError, match="remoteTokenBytes32"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_bridge_program_without_aleo_suffix_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(bridgeProgram="not_a_program") + with pytest.raises(ConfigurationError, match="bridgeProgram"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_negative_remote_domain_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(remoteDomain=-1) + with pytest.raises(ConfigurationError, match="remoteDomain"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods + + +def test_non_digit_minimum_amount_atomic_is_refused_before_any_contract_read(): + eth, w3 = sepolia() + route = _corrupted_xreserve_route(minimumAmountAtomic="2_000_000") + with pytest.raises(ConfigurationError, match="minimumAmountAtomic"): + eth.quote_deposit_usdc(ALEO, amount="2", route=route) + assert "eth_call" not in w3.provider.methods From cfe3100dec678945a51a6b28b9b3bc10ab2f6cc4 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:30:42 -0400 Subject: [PATCH 35/94] feat(bridge): eth.deposit_usdc with DepositedToRemote re-verification and Circle message hash derivation Also fixes tests/fakes/fake_web3.py's make_bridge() to forward checkpoints= to Bridge(...) instead of leaking it into FakeAleo(**aleo_kwargs). --- bridge-sdk/python/aleo_bridge/eth.py | 110 +++++++++++++- bridge-sdk/tests/fakes/fake_web3.py | 8 +- bridge-sdk/tests/test_eth_xreserve_execute.py | 143 ++++++++++++++++++ 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/test_eth_xreserve_execute.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 6818e9cd..ca68fba4 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -13,12 +13,13 @@ from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep -from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, ZERO_ADDRESS +from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, XRESERVE_ABI, ZERO_ADDRESS from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError) from .registry import Asset, Chain, Registry, Route -from .types import DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, Receipt, Status, Step +from .types import (DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, Receipt, Status, + Step) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -701,5 +702,110 @@ def finish(outcome: EvmOutcome) -> DispatchReceipt: return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, store=self.bridge.checkpoints) + # -- xReserve execute ----------------------------------------------------------------------- + + @staticmethod + def _xreserve_protocol_state(route: Route, q: _XReserveQuote, *, approval_tx_ids: list[str], sender: str | None, + mint_mode: str, intended_recipient: str) -> dict[str, Any]: + return { + "routeId": route.id, "approvalTxIds": list(approval_tx_ids), "sourceSender": sender, + "mintMode": mint_mode, "intendedRecipient": intended_recipient, + "xReserveContract": q.xreserve_contract, "tokenAddress": q.token, "sourceChainId": q.source_chain_id, + "remoteDomain": q.remote_domain, "remoteRecipientBytes32": "0x" + q.remote_recipient_bytes32.hex(), + "hookData": "0x" + q.hook_data.hex(), "amountAtomic": str(q.amount_atomic), "maxFeeAtomic": str(q.max_fee_atomic), + } + + def _confirmed_deposit_receipt(self, route: Route, q: _XReserveQuote, *, owner: str, approval_tx_ids: list[str], + source_tx_id: str, receipt: Any, mint_mode: str, intended_recipient: str) -> Receipt: + """Brief §3.2 confirm: find the xReserve ``DepositedToRemote`` log, re-verify every field, derive nonce/payload/hash.""" + from web3.logs import DISCARD + + Web3 = _web3().Web3 + if int(receipt["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {source_tx_id}") + xreserve = self._contract(q.xreserve_contract, XRESERVE_ABI) + events = [ev for ev in xreserve.events.DepositedToRemote().process_receipt(receipt, errors=DISCARD) + if Web3.to_checksum_address(ev["address"]) == q.xreserve_contract] + if not events: + raise BridgeError("Confirmed receipt does not contain a valid DepositedToRemote event") + ev = events[-1] + a = ev["args"] + if (Web3.to_checksum_address(a["localToken"]) != q.token + or Web3.to_checksum_address(a["localDepositor"]) != owner + or int(a["value"]) != q.amount_atomic + or int(a["remoteDomain"]) != q.remote_domain + or bytes(a["remoteRecipient"]) != q.remote_recipient_bytes32 + or bytes(a["remoteToken"]) != q.remote_token_bytes32 + or int(a["maxFee"]) != q.max_fee_atomic + or bytes(a["hookData"]) != q.hook_data): + raise BridgeError("DepositedToRemote event does not match the prepared transfer") + log_index = int(ev["logIndex"]) + if log_index < 0: + raise BridgeError("DepositedToRemote log index is missing or invalid") + # xReserve identifies a deposit by (source domain, tx hash, log index); the ordered payload is + # what Circle signs, so its keccak is the only safe attestation lookup key. + nonce = encoding.xreserve_deposit_nonce(q.source_domain, bytes.fromhex(source_tx_id[2:]), log_index) + payload = encoding.xreserve_deposit_payload( + amount=int(a["value"]), remote_domain=int(a["remoteDomain"]), remote_token=bytes(a["remoteToken"]), + remote_recipient=bytes(a["remoteRecipient"]), local_token=Web3.to_checksum_address(a["localToken"]), + depositor=Web3.to_checksum_address(a["localDepositor"]), max_fee=int(a["maxFee"]), nonce=nonce, + hook_data=bytes(a["hookData"])) + message_hash = "0x" + encoding.xreserve_message_hash(payload).hex() + state = self._xreserve_protocol_state(route, q, approval_tx_ids=approval_tx_ids, sender=owner, + mint_mode=mint_mode, intended_recipient=intended_recipient) + state.update({"sourceDomain": q.source_domain, "remoteDomain": q.remote_domain, "depositLogIndex": log_index, + "nonce": "0x" + nonce.hex(), "payload": "0x" + payload.hex(), "messageHash": message_hash, + "bridgeProgram": q.bridge_program, "wrapperProgram": q.wrapper_program}) + return Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, + source_tx_id=source_tx_id, protocol_state=state) + + def _xreserve_result(self, route: Route, q: _XReserveQuote, outcome: EvmOutcome, *, mint_mode: str, + intended_recipient: str) -> DepositReceipt: + approvals = list(outcome.approval_tx_ids) + if outcome.status == "CONFIRMED": + receipt = self._confirmed_deposit_receipt(route, q, owner=outcome.sender, approval_tx_ids=approvals, + source_tx_id=outcome.source_tx_id, receipt=outcome.receipt, + mint_mode=mint_mode, intended_recipient=intended_recipient) + return DepositReceipt(transaction_id=outcome.source_tx_id, route_id=route.id, message_hash=receipt.id, + nonce=receipt.protocol_state["nonce"], receipt=receipt) + rid = outcome.source_tx_id or approvals[-1] + receipt = Receipt(id=rid, protocol="xreserve", status=Status(outcome.status), source_tx_id=outcome.source_tx_id, + protocol_state=self._xreserve_protocol_state(route, q, approval_tx_ids=approvals, sender=outcome.sender, + mint_mode=mint_mode, intended_recipient=intended_recipient)) + return DepositReceipt(transaction_id=rid, route_id=route.id, message_hash="", nonce="", receipt=receipt) + + def deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str = "public", secret_nonce: str = "0scalar") -> EvmCall[DepositReceipt]: + """Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + + ``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private + record), or ``private`` (deposit addressed to the shielded wrapper program; you must later + run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, + which the SDK never stores). Approves exactly the amount only when the allowance is + short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` + carries Circle's message hash (receipt id) and the deposit nonce. + """ + route = self._xreserve_route() + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender, mint_mode=mint_mode) + latest: dict[str, _XReserveQuote] = {} + + def steps(owner: str) -> list[EvmStep]: + q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) # fresh balance/allowance + latest["q"] = q + out: list[EvmStep] = [] + if q.allowance_atomic < atomic: + out.append(EvmStep("approve", q.token, self._erc20(q.token).encode_abi("approve", args=[q.xreserve_contract, atomic]))) + xreserve = self._contract(q.xreserve_contract, XRESERVE_ABI) + out.append(EvmStep("main", q.xreserve_contract, xreserve.encode_abi( + "depositToRemote", args=[atomic, q.remote_domain, q.remote_recipient_bytes32, q.token, q.max_fee_atomic, q.hook_data]), 0)) + return out + + def finish(outcome: EvmOutcome) -> DepositReceipt: + return self._xreserve_result(route, latest["q"], outcome, mint_mode=mint_mode, intended_recipient=recipient) + + return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, store=self.bridge.checkpoints) + __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index 0da3a413..946780b1 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -247,10 +247,14 @@ def fake_web3(**config: Any) -> Web3: return Web3(FakeRpcProvider(**config)) -def make_bridge(*, ethereum: Any = None, environment: str | None = None, **aleo_kwargs: Any) -> Any: +def make_bridge(*, ethereum: Any = None, environment: str | None = None, checkpoints: Any = None, + **aleo_kwargs: Any) -> Any: """A ``Bridge`` over a fresh ``FakeAleo`` (mainnet unless *environment* says otherwise), wired with *ethereum* so ``bridge.eth`` works. + ``checkpoints`` forwards to ``Bridge(..., checkpoints=...)`` (a ``CheckpointStore``, e.g. + ``FileCheckpointStore``); it is never a ``FakeAleo`` constructor argument. + Kept here (rather than in ``tests/conftest.py``) so ``eth.py`` tests can import one fixture factory alongside ``fake_web3`` without pulling in pytest fixtures. """ @@ -261,4 +265,4 @@ def make_bridge(*, ethereum: Any = None, environment: str | None = None, **aleo_ aleo_kwargs.setdefault("mappings", default_mappings()) if environment is not None: aleo_kwargs.setdefault("network_name", environment) - return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum, environment=environment) + return Bridge(FakeAleo(**aleo_kwargs), ethereum=ethereum, environment=environment, checkpoints=checkpoints) diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py new file mode 100644 index 00000000..0752c464 --- /dev/null +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -0,0 +1,143 @@ +import json + +import pytest +from eth_abi import decode +from eth_account import Account +from eth_utils import keccak +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.errors import BridgeError +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import DepositReceipt, Status +from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge, tx_hash_for + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" +XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +APPROVE = keccak(text="approve(address,uint256)")[:4].hex() +DEPOSIT = keccak(text="depositToRemote(uint256,uint32,bytes32,address,uint256,bytes)")[:4].hex() + + +def deposit_logs(*, remote_token32=REMOTE_TOKEN, value_override=None, log_index=3): + """Echo the depositToRemote calldata back as a DepositedToRemote log, optionally corrupting one field.""" + def logs(tx): + if tx["to"] != Web3.to_checksum_address(XRESERVE) or tx["data"][2:10] != DEPOSIT: + return [] + value, remote_domain, remote_recipient, local_token, max_fee, hook = decode( + ["uint256", "uint32", "bytes32", "address", "uint256", "bytes"], bytes.fromhex(tx["data"][10:])) + return [deposited_log(XRESERVE, local_token=Web3.to_checksum_address(local_token), depositor=tx["from"], + remote_recipient32=remote_recipient, value=value_override or value, remote_domain=remote_domain, + remote_token32=remote_token32, max_fee=max_fee, hook_data=hook, tx_hash=tx["hash"], log_index=log_index)] + return logs + + +def setup(*, allowance=0, logs=None, checkpoints=None): + w3 = fake_web3(chain_id=11155111, token_balances={(USDC, ACCT.address): 3_000_000}, + allowances={(USDC, ACCT.address, XRESERVE): allowance}) + w3.provider.receipt_logs = logs or deposit_logs() + bridge = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3, private_key=KEY), checkpoints=checkpoints) + return bridge.eth, w3 + + +def test_build_lists_approve_then_deposit_with_zero_value(): + eth, w3 = setup() + txs = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").build() + assert [t["to"] for t in txs] == [Web3.to_checksum_address(USDC), Web3.to_checksum_address(XRESERVE)] + assert txs[0]["data"][2:10] == APPROVE and txs[1]["data"][2:10] == DEPOSIT + assert txs[0]["value"] == 0 and txs[1]["value"] == 0 and w3.provider.sent == [] + + +def test_record_mode_deposit_derives_nonce_payload_and_message_hash(): + eth, w3 = setup() + seen = [] + result = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").send(poll_seconds=0.001, on_checkpoint=seen.append) + assert isinstance(result, DepositReceipt) + sent = w3.provider.sent + assert len(sent) == 2 and sent[1]["value"] == 0 and sent[1]["to"] == Web3.to_checksum_address(XRESERVE) + assert sent[0]["data"][2:].lower() == APPROVE + XRESERVE[2:].lower().rjust(64, "0") + format(2_000_000, "064x") + receipt = result.receipt + assert receipt.status == Status.ATTESTATION_PENDING and receipt.protocol == "xreserve" + assert receipt.source_tx_id == tx_hash_for(2) == result.transaction_id + hook = b"\x01" + bytes(64) + recipient32 = encoding.aleo_address_to_bytes32(ALEO) + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(tx_hash_for(2)[2:]), 3) + payload = encoding.xreserve_deposit_payload(amount=2_000_000, remote_domain=10002, remote_token=REMOTE_TOKEN, + remote_recipient=recipient32, local_token=USDC, depositor=ACCT.address, + max_fee=100_000, nonce=nonce, hook_data=hook) + message_hash = "0x" + encoding.xreserve_message_hash(payload).hex() + assert len(payload) == 305 + assert receipt.id == message_hash == result.message_hash == receipt.protocol_state["messageHash"] + assert result.nonce == "0x" + nonce.hex() == receipt.protocol_state["nonce"] + assert receipt.protocol_state["payload"] == "0x" + payload.hex() + state = receipt.protocol_state + assert state["routeId"] == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and state["approvalTxIds"] == [tx_hash_for(1)] + assert state["sourceSender"] == ACCT.address and state["mintMode"] == "record" and state["intendedRecipient"] == ALEO + assert state["xReserveContract"] == Web3.to_checksum_address(XRESERVE) and state["tokenAddress"] == Web3.to_checksum_address(USDC) + assert state["sourceChainId"] == 11155111 and state["sourceDomain"] == 0 and state["remoteDomain"] == 10002 + assert state["remoteRecipientBytes32"] == "0x" + recipient32.hex() and state["hookData"] == "0x" + hook.hex() + assert state["amountAtomic"] == "2000000" and state["maxFeeAtomic"] == "100000" and state["depositLogIndex"] == 3 + assert state["bridgeProgram"] == "test_usdcx_bridge_v2.aleo" and state["wrapperProgram"] == "shielded_usdcx_wrapper.aleo" + assert [cp.source for cp in seen] == [ + {"approvalTransactionIds": [tx_hash_for(1)], "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2), "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2), "hookData": "0x" + hook.hex()}, + ] + assert seen[-1].id == message_hash and seen[-1].intent["mintMode"] == "record" + + +def test_sufficient_allowance_skips_approval(): + eth, w3 = setup(allowance=5_000_000) + result = eth.deposit_usdc(ALEO, amount_atomic=2_000_000).send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 and result.receipt.protocol_state["approvalTxIds"] == [] + assert result.receipt.protocol_state["hookData"] == "0x" + "00" * 65 + + +def test_private_mode_deposits_to_wrapper_and_never_persists_the_secret(): + eth, _ = setup(allowance=5_000_000) + seen = [] + result = eth.deposit_usdc(ALEO, amount="2", mint_mode="private", secret_nonce="7scalar").send( + poll_seconds=0.001, on_checkpoint=seen.append) + wrapper32 = encoding.aleo_address_to_bytes32(encoding.aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet")) + state = result.receipt.protocol_state + assert state["remoteRecipientBytes32"] == "0x" + wrapper32.hex() and state["intendedRecipient"] == ALEO + assert state["hookData"] == "0x" + encoding.xreserve_hook_data("private", ALEO, "testnet", "7scalar").hex() + assert "7scalar" not in json.dumps(state) and all("7scalar" not in cp.to_json() for cp in seen) + assert seen[0].source["hookData"].startswith("0x02") and len(seen[0].source["hookData"]) == 132 + + +def test_event_mismatch_or_absence_raises(): + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(value_override=1)) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(remote_token32=bytes(32))) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + eth, _ = setup(allowance=5_000_000, logs=lambda tx: []) + with pytest.raises(BridgeError, match="DepositedToRemote"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +def test_timeouts_return_pending_receipts(): + eth, w3 = setup() + w3.provider.pending.add(tx_hash_for(1)) + result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001) + assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None + assert result.receipt.id == tx_hash_for(1) and result.message_hash == "" and result.nonce == "" + assert len(w3.provider.sent) == 1 + eth, w3 = setup(allowance=5_000_000) + w3.provider.pending.add(tx_hash_for(1)) + seen = [] + result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) + assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1), "hookData": "0x" + "00" * 65}] + + +def test_reverted_deposit_raises(): + eth, w3 = setup(allowance=5_000_000) + w3.provider.reverted.add(tx_hash_for(1)) + with pytest.raises(BridgeError, match="reverted"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) From 5729f469f4e28ecf1c313b09f7021cdfd41e97d0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:35:42 -0400 Subject: [PATCH 36/94] feat(bridge-sdk): eth.source_status for approval and confirming branches, Mailbox.delivered, balances --- bridge-sdk/python/aleo_bridge/eth.py | 165 ++++++++++++++++++++++++++- bridge-sdk/tests/fakes/fake_web3.py | 9 ++ bridge-sdk/tests/test_eth_status.py | 154 +++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 bridge-sdk/tests/test_eth_status.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index ca68fba4..1dddf1b9 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -14,9 +14,9 @@ from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, XRESERVE_ABI, ZERO_ADDRESS -from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, - InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, - RouteNotFoundError, RouteUnavailableError) +from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, + InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, + RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Asset, Chain, Registry, Route from .types import (DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, Receipt, Status, Step) @@ -31,6 +31,9 @@ def _web3(): return web3 +_HASH_RE = re.compile(r"^0x[0-9a-fA-F]{64}$") + + def _eth_account(): try: from eth_account import Account @@ -807,5 +810,161 @@ def finish(outcome: EvmOutcome) -> DepositReceipt: return EvmCall(self.conn, plan=plan, registry=self.registry, steps=steps, finish=finish, store=self.bridge.checkpoints) + # -- status --------------------------------------------------------------------------------- + + @staticmethod + def _require_hash(value: Any, what: str) -> str: + if not isinstance(value, str) or not _HASH_RE.match(value): + raise CheckpointInvalidError(f"Receipt is missing a valid {what}") + return value + + @staticmethod + def _failed(receipt: Receipt, key: str, message: str) -> Receipt: + return receipt.replace(status=Status.FAILED, next_action=None, + protocol_state={**receipt.protocol_state, key: message}) + + def _validate_hyperlane_state(self, route: Route, plan: Plan, receipt: Receipt) -> bytes: + """Bind every value that affects the dispatch before trusting checkpointed transaction ids.""" + state = receipt.protocol_state + recipient32 = encoding.aleo_address_to_bytes32(plan.recipient) + if (receipt.protocol != "hyperlane" + or state.get("destinationDomain") != int(route.metadata["destinationDomain"]) + or state.get("amountAtomic") != str(plan.amount_atomic) + or not isinstance(state.get("recipientBytes32"), str) + or state["recipientBytes32"].lower() != "0x" + recipient32.hex()): + raise CheckpointInvalidError("Hyperlane checkpoint does not match the prepared transfer") + ids = state.get("approvalTxIds", []) + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.match(i) for i in ids): + raise CheckpointInvalidError("Hyperlane checkpoint contains invalid approval transaction ids") + return recipient32 + + def _hyperlane_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> Receipt: + self._validate_hyperlane_state(route, plan, receipt) + self.assert_chain(route) + source_tx_id = self._require_hash(receipt.source_tx_id, "source transaction id") + observed = self.conn.get_receipt(source_tx_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM transaction reverted: {source_tx_id}") + message_id = self._message_id_from_receipt(route, observed) + state = dict(receipt.protocol_state) + if message_id is not None: + state["messageId"] = message_id + return receipt.replace(id=message_id or source_tx_id, status=Status.DELIVERY_PENDING, protocol_state=state) + + def _xreserve_quote_from_state(self, route: Route, plan: Plan, receipt: Receipt) -> _XReserveQuote: + """veil ``resumeQuote``: rebuild the deposit arguments from saved state and bind them to the plan.""" + Web3 = _web3().Web3 + s = receipt.protocol_state + if receipt.protocol != "xreserve": + raise CheckpointInvalidError("Checkpoint does not match the prepared xReserve route") + if s.get("mintMode") != plan.mint_mode or s.get("intendedRecipient") != plan.recipient: + raise CheckpointInvalidError("Checkpoint does not match the prepared xReserve recipient") + try: + ok = (Web3.is_address(s["xReserveContract"]) and Web3.is_address(s["tokenAddress"]) + and isinstance(s["sourceChainId"], int) and isinstance(s["remoteDomain"], int) + and _HASH_RE.match(s["remoteRecipientBytes32"]) is not None + and isinstance(s["hookData"], str) and len(s["hookData"]) == 132 and s["hookData"].startswith("0x") + and str(s["amountAtomic"]).isdigit() and str(s["maxFeeAtomic"]).isdigit()) + except (KeyError, TypeError): + ok = False + if not ok: + raise CheckpointInvalidError("Checkpoint contains invalid xReserve submission state") + ids = s.get("approvalTxIds", []) + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.match(i) for i in ids): + raise CheckpointInvalidError("Checkpoint contains invalid xReserve approval transaction ids") + meta = self._xreserve_metadata(route) # reuse the registry validator rather than trusting raw metadata again + return _XReserveQuote( + xreserve_contract=Web3.to_checksum_address(s["xReserveContract"]), token=Web3.to_checksum_address(s["tokenAddress"]), + source_chain_id=int(s["sourceChainId"]), source_domain=meta.source_domain, remote_domain=int(s["remoteDomain"]), + remote_token_bytes32=meta.remote_token_bytes32, + remote_recipient_bytes32=bytes.fromhex(s["remoteRecipientBytes32"][2:]), amount_atomic=int(s["amountAtomic"]), + max_fee_atomic=int(s["maxFeeAtomic"]), hook_data=bytes.fromhex(s["hookData"][2:]), balance_atomic=0, allowance_atomic=0, + bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) + + def _observed_owner(self, plan: Plan, receipt: Receipt | None) -> str: + """Prefer the sender committed to the receipt or plan so read-only recovery never needs a signer.""" + Web3 = _web3().Web3 + saved = receipt.protocol_state.get("sourceSender") if receipt is not None else None + for candidate in (saved, plan.sender, self.conn.address): + if isinstance(candidate, str) and Web3.is_address(candidate): + return Web3.to_checksum_address(candidate) + raise ConfigurationError("Read-only EVM access requires the prepared sender address (plan.sender or protocol_state.sourceSender)") + + def _xreserve_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> Receipt: + q = self._xreserve_quote_from_state(route, plan, receipt) + owner = self._observed_owner(plan, receipt) + source_tx_id = self._require_hash(receipt.source_tx_id, "xReserve source transaction id") + observed = self.conn.get_receipt(source_tx_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM transaction reverted: {source_tx_id}") + return self._confirmed_deposit_receipt(route, q, owner=owner, approval_tx_ids=list(receipt.protocol_state["approvalTxIds"]), + source_tx_id=source_tx_id, receipt=observed, mint_mode=plan.mint_mode, + intended_recipient=plan.recipient) + + def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: + """One read-only refresh of an Ethereum source leg (brief §2.4 branches 1 and 3, plus xReserve SOURCE_CONFIRMING). + + ``SOURCE_APPROVAL_PENDING``: approval receipt → ``SOURCE_SUBMISSION_PENDING`` (or ``FAILED`` on revert). + ``SOURCE_CONFIRMING``: Hyperlane → ``DELIVERY_PENDING`` with the ``DispatchId`` message id; + xReserve → ``ATTESTATION_PENDING`` after re-verifying the ``DepositedToRemote`` event. + An unmined transaction returns the receipt unchanged. Never signs. + + Deviation from veil (deliberate): veil raises for a reverted Hyperlane/xReserve *source* + transaction but returns ``FAILED`` for a reverted approval. Here every reverted source + transaction observed at this stage becomes ``FAILED`` with ``protocol_state["sourceError"]`` — + one uniform rule that plan 4's ``get_status``/``wait`` can rely on without a protocol switch. + ``send()`` still raises on revert. + """ + route = self._route_for_plan(plan) + if receipt.protocol != plan.protocol or receipt.protocol_state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Receipt does not match the prepared route") + if receipt.status == Status.SOURCE_APPROVAL_PENDING: + approval_id = self._require_hash(receipt.id, "EVM approval transaction id") + observed = self.conn.get_receipt(approval_id) + if observed is None: + return receipt + if int(observed["status"]) == 0: + return self._failed(receipt, "sourceError", f"EVM approval transaction reverted: {approval_id}") + return receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + if receipt.status == Status.SOURCE_CONFIRMING: + if route.protocol == "hyperlane": + return self._hyperlane_source_status(route, plan, receipt) + return self._xreserve_source_status(route, plan, receipt) + raise BridgeError("source_status refreshes SOURCE_APPROVAL_PENDING and SOURCE_CONFIRMING receipts only; " + "use bridge.get_status for later stages") + + def _mailbox_address(self) -> str: + for route in self.registry.routes(protocol="hyperlane", include_unavailable=True, environment=self.bridge.environment): + chains = {self.registry.asset(route.source_asset_id).chain_id, self.registry.asset(route.destination_asset_id).chain_id} + mailbox = route.metadata.get("mailboxAddress") + if self.chain.id in chains and isinstance(mailbox, str): + return mailbox + raise UnsupportedRouteError(f"No Hyperlane Mailbox is configured for {self.chain.id} in registry {self.registry.version}") + + def is_delivered(self, message_id: str | bytes) -> bool: + """``Mailbox.delivered(bytes32)`` on this chain — the canonical Aleo → Ethereum delivery signal.""" + raw = bytes.fromhex(message_id[2:]) if isinstance(message_id, str) and message_id.startswith("0x") else message_id + if not isinstance(raw, (bytes, bytearray)) or len(raw) != 32: + raise BridgeError("Hyperlane delivery requires a 32-byte message id") + return bool(self._contract(self._mailbox_address(), MAILBOX_ABI).functions.delivered(bytes(raw)).call()) + + def balance(self, asset: Any, *, address: str | None = None) -> int: + """Atomic balance of ``asset`` (native via ``eth_getBalance``, ERC-20 via ``balanceOf``) for ``address`` or the connection's account.""" + target = self._asset(asset) + owner = self._owner(address) + if owner is None: + raise ConfigurationError("balance() needs an address: pass address= or configure a signer") + if target.locator is None: + raise UnsupportedRouteError(f"{target.id} has no on-chain locator") + if target.locator.kind == "native": + return int(self.conn.w3.eth.get_balance(owner)) + if target.locator.kind == "evm-contract": + return int(self._erc20(target.locator.value).functions.balanceOf(owner).call()) + raise UnsupportedRouteError(f"{target.id} is not an EVM asset") + __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index 946780b1..dea1f7da 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -133,6 +133,15 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N def _ok(self, result: Any) -> dict: return {"jsonrpc": "2.0", "id": 1, "result": result} + def add_receipt(self, tx_hash: str, *, status: int = 1, logs: list[dict] | None = None, block_number: int = 0x65, + sender: str = ZERO_ADDRESS, to: str = ZERO_ADDRESS) -> None: + """Serve a receipt for a hash the fake never accepted itself (recovery / status tests).""" + self.receipts[tx_hash] = { + "transactionHash": tx_hash, "status": _hex(status), "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, + "transactionIndex": "0x0", "from": to_checksum_address(sender), "to": to_checksum_address(to), + "cumulativeGasUsed": "0x1", "gasUsed": "0x1", "effectiveGasPrice": "0x1", "type": "0x2", + "contractAddress": None, "logsBloom": "0x" + "00" * 256, "logs": logs or []} + def make_request(self, method: str, params: Any) -> dict: self.methods.append(method) if method == "eth_chainId": diff --git a/bridge-sdk/tests/test_eth_status.py b/bridge-sdk/tests/test_eth_status.py new file mode 100644 index 00000000..b923a703 --- /dev/null +++ b/bridge-sdk/tests/test_eth_status.py @@ -0,0 +1,154 @@ +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.errors import BridgeError, CheckpointInvalidError, ConfigurationError, UnsupportedRouteError +from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_web3 import deposited_log, dispatch_id_log, fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO32 = encoding.aleo_address_to_bytes32(ALEO) +WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +SEPOLIA_USDC, SEPOLIA_XRESERVE = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "0x008888878f94C0d87defdf0B07f46B93C1934442" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +H1, H2 = "0x" + "11" * 32, "0x" + "22" * 32 +MESSAGE_ID = bytes.fromhex("ab" * 32) +DELIVERED_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" +WBTC_ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") + + +def hyperlane_state(**overrides): + state = {"routeId": WBTC_ROUTE.id, "approvalTxIds": [H1], "sourceSender": ACCT.address, + "recipientBytes32": "0x" + ALEO32.hex(), "destinationDomain": 1634493807, + "nativeValueAtomic": "50000", "amountAtomic": "100000"} + state.update(overrides) + return state + + +def xreserve_state(**overrides): + state = {"routeId": USDC_ROUTE.id, "approvalTxIds": [H1], "sourceSender": ACCT.address, "mintMode": "public", + "intendedRecipient": ALEO, "xReserveContract": SEPOLIA_XRESERVE, "tokenAddress": SEPOLIA_USDC, + "sourceChainId": 11155111, "remoteDomain": 10002, "remoteRecipientBytes32": "0x" + ALEO32.hex(), + "hookData": "0x" + "00" * 65, "amountAtomic": "2000000", "maxFeeAtomic": "100000"} + state.update(overrides) + return state + + +def mainnet(*, signed=True, **config): + w3 = fake_web3(**config) + conn = Ethereum(w3=w3, private_key=KEY) if signed else Ethereum(w3=w3) + return make_bridge(ethereum=conn).eth, w3 + + +WBTC_PLAN = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) +USDC_PLAN = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + + +def test_approval_pending_branch(): + eth, w3 = mainnet() + receipt = Receipt(id=H1, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=hyperlane_state()) + assert eth.source_status(WBTC_PLAN, receipt) is receipt # no receipt yet → unchanged + w3.provider.add_receipt(H1) + advanced = eth.source_status(WBTC_PLAN, receipt) + assert advanced.status == Status.SOURCE_SUBMISSION_PENDING and advanced.id == H1 + w3.provider.add_receipt(H1, status=0) + failed = eth.source_status(WBTC_PLAN, receipt) + assert failed.status == Status.FAILED and failed.protocol_state["sourceError"] == f"EVM approval transaction reverted: {H1}" + assert "eth_sendRawTransaction" not in w3.provider.methods + + +def test_hyperlane_source_confirming_branch(): + eth, w3 = mainnet() + receipt = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=hyperlane_state()) + assert eth.source_status(WBTC_PLAN, receipt) is receipt + w3.provider.add_receipt(H2, logs=[dispatch_id_log(MAILBOX, MESSAGE_ID, tx_hash=H2)], sender=ACCT.address, to=WBTC_ROUTER) + advanced = eth.source_status(WBTC_PLAN, receipt) + assert advanced.status == Status.DELIVERY_PENDING and advanced.id == Web3.to_hex(MESSAGE_ID) + assert advanced.source_tx_id == H2 and advanced.protocol_state["messageId"] == Web3.to_hex(MESSAGE_ID) + w3.provider.add_receipt(H2) # confirmed, no DispatchId log + no_id = eth.source_status(WBTC_PLAN, receipt) + assert no_id.status == Status.DELIVERY_PENDING and no_id.id == H2 and "messageId" not in no_id.protocol_state + w3.provider.add_receipt(H2, status=0) + failed = eth.source_status(WBTC_PLAN, receipt) + assert failed.status == Status.FAILED and failed.protocol_state["sourceError"] == f"EVM transaction reverted: {H2}" + + +def test_hyperlane_state_must_match_plan(): + eth, _ = mainnet() + bad = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=hyperlane_state(amountAtomic="1")) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared transfer"): + eth.source_status(WBTC_PLAN, bad) + wrong_route = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=hyperlane_state(routeId="hyperlane:ethereum/eth->aleo/eth")) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + eth.source_status(WBTC_PLAN, wrong_route) + missing_hash = Receipt(id=H2, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, protocol_state=hyperlane_state()) + with pytest.raises(CheckpointInvalidError, match="source transaction id"): + eth.source_status(WBTC_PLAN, missing_hash) + other = Receipt(id=H2, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=H2, protocol_state=hyperlane_state()) + with pytest.raises(BridgeError, match="SOURCE_APPROVAL_PENDING and SOURCE_CONFIRMING"): + eth.source_status(WBTC_PLAN, other) + + +def test_xreserve_source_confirming_branch_works_read_only(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth # no signer: uses sourceSender + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=xreserve_state()) + assert eth.source_status(USDC_PLAN, receipt) is receipt + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, + value=2_000_000, remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, + hook_data=bytes(65), tx_hash=H2, log_index=3) + w3.provider.add_receipt(H2, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + advanced = eth.source_status(USDC_PLAN, receipt) + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(H2[2:]), 3) + assert advanced.status == Status.ATTESTATION_PENDING and advanced.protocol_state["nonce"] == "0x" + nonce.hex() + assert advanced.id == advanced.protocol_state["messageHash"] and advanced.protocol_state["depositLogIndex"] == 3 + assert advanced.protocol_state["approvalTxIds"] == [H1] and advanced.source_tx_id == H2 + w3.provider.add_receipt(H2, status=0) + assert eth.source_status(USDC_PLAN, receipt).status == Status.FAILED + + +def test_xreserve_state_validation(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3, private_key=KEY)).eth + for bad in (xreserve_state(mintMode="private"), xreserve_state(intendedRecipient="aleo1" + "q" * 58), + xreserve_state(hookData="0x00"), xreserve_state(amountAtomic="abc")): + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, protocol_state=bad) + with pytest.raises(CheckpointInvalidError): + eth.source_status(USDC_PLAN, receipt) + no_owner = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=xreserve_state(sourceSender=None)) + plan_without_sender = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=None) + read_only = make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth + with pytest.raises(ConfigurationError, match="prepared sender"): + read_only.source_status(plan_without_sender, no_owner) + + +def test_is_delivered_reads_mailbox(): + eth, w3 = mainnet(delivered={DELIVERED_ID}) + assert eth.is_delivered(DELIVERED_ID) is True + assert eth.is_delivered(bytes.fromhex(DELIVERED_ID[2:])) is True + assert eth.is_delivered("0x" + "00" * 32) is False + assert w3.provider.methods.count("eth_call") == 3 + with pytest.raises(BridgeError, match="32-byte message id"): + eth.is_delivered("0x1234") + testnet = make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth + with pytest.raises(UnsupportedRouteError, match="Mailbox"): + testnet.is_delivered(DELIVERED_ID) + + +def test_balance_native_and_erc20(): + eth, _ = mainnet(eth_balances={ACCT.address: 5}, token_balances={(WBTC, ACCT.address): 7}) + assert eth.balance("eth") == 5 and eth.balance("ethereum/wbtc") == 7 and eth.balance("usdt") == 0 + read_only, _ = mainnet(signed=False, eth_balances={ACCT.address: 5}) + with pytest.raises(ConfigurationError, match="address"): + read_only.balance("eth") + assert read_only.balance("eth", address=ACCT.address) == 5 From 075655b1a268399b3a0631ced7c7583ffc648530 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 15:45:10 -0400 Subject: [PATCH 37/94] feat(bridge-sdk): eth.recover_source log-scan recovery for Hyperlane dispatches and xReserve deposits --- bridge-sdk/python/aleo_bridge/eth.py | 211 ++++++++++++++++++++++++++ bridge-sdk/tests/fakes/fake_web3.py | 7 + bridge-sdk/tests/test_eth_recover.py | 215 +++++++++++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 bridge-sdk/tests/test_eth_recover.py diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 1dddf1b9..64ea85de 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -14,6 +14,7 @@ from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, XRESERVE_ABI, ZERO_ADDRESS +from .checkpoint import Checkpoint from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) @@ -937,6 +938,216 @@ def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: raise BridgeError("source_status refreshes SOURCE_APPROVAL_PENDING and SOURCE_CONFIRMING receipts only; " "use bridge.get_status for later stages") + # -- recovery ----------------------------------------------------------------------------- + + def _checkpoint_approvals(self, checkpoint: Checkpoint) -> list[str]: + approvals = list((checkpoint.source or {}).get("approvalTransactionIds", [])) + if any(not isinstance(a, str) or not _HASH_RE.match(a) for a in approvals): + raise CheckpointInvalidError("Bridge checkpoint contains an invalid approval transaction id") + return approvals + + def _approval_scan_block(self, approvals: list[str]) -> int | None: + """Highest block of a confirmed approval; an unresolved hash is skipped, a reverted one is an error.""" + block: int | None = None + for approval in approvals: + observed = self.conn.get_receipt(approval) + if observed is None: + continue + if int(observed["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {approval}") + number = int(observed["blockNumber"]) + block = number if block is None or number > block else block + return block + + def _recover_hyperlane_from_history(self, route: Route, recipient32: bytes, receipt: Receipt, approvals: list[str], + *, required: bool) -> Receipt | None: + """Scan router ``SentTransferRemote`` logs after the last confirmed approval; sender and router must match.""" + from web3.exceptions import TransactionNotFound + + Web3 = _web3().Web3 + sender = receipt.protocol_state.get("sourceSender") + if not isinstance(sender, str) or not Web3.is_address(sender): + if required: + raise BridgeError("Cannot safely resume Hyperlane without the source account used by the approval") + return None + from_block = self._approval_scan_block(approvals) + if from_block is None: + if required: + raise BridgeError("Cannot safely resume Hyperlane because no confirmed approval block is available " + "for source history verification") + return None + amount = int(receipt.protocol_state["amountAtomic"]) + meta = self._hyperlane_metadata(route) # reuse the registry validator before touching the router + destination = meta.destination_domain + router = meta.router + warp = self._contract(router, WARP_ROUTE_ABI) + topic = Web3.keccak(text="SentTransferRemote(uint32,bytes32,uint256)") + candidates: list[str] = [] + for log in self.conn.w3.eth.get_logs({"address": router, "fromBlock": from_block}): + if not log["topics"] or bytes(log["topics"][0]) != bytes(topic): + continue + args = warp.events.SentTransferRemote().process_log(log)["args"] + tx_hash = Web3.to_hex(log["transactionHash"]) + if (int(args["destination"]) == destination and bytes(args["recipient"]) == recipient32 + and int(args["amount"]) == amount and tx_hash not in candidates): + candidates.append(tx_hash) + matches: list[Receipt] = [] + for tx_hash in candidates: + try: + tx = self.conn.w3.eth.get_transaction(tx_hash) + except TransactionNotFound: + continue + observed = self.conn.get_receipt(tx_hash) + if (tx is None or observed is None or tx["to"] is None + or Web3.to_checksum_address(tx["from"]) != Web3.to_checksum_address(sender) + or Web3.to_checksum_address(tx["to"]) != router): + continue + if int(observed["status"]) == 0: + raise BridgeError(f"EVM transaction reverted: {tx_hash}") + message_id = self._message_id_from_receipt(route, observed) + state = dict(receipt.protocol_state) + if message_id is not None: + state["messageId"] = message_id + matches.append(receipt.replace(id=message_id or tx_hash, status=Status.DELIVERY_PENDING, + source_tx_id=tx_hash, protocol_state=state)) + if len(matches) > 1: + raise BridgeError("Multiple matching Hyperlane dispatches were found; recovery cannot safely choose one source transaction") + return matches[0] if matches else None + + def _recover_hyperlane(self, route: Route, plan: Plan, checkpoint: Checkpoint, *, required: bool) -> Receipt: + Web3 = _web3().Web3 + recipient32 = encoding.aleo_address_to_bytes32(plan.recipient) + approvals = self._checkpoint_approvals(checkpoint) + sender = Web3.to_checksum_address(plan.sender) if plan.sender and Web3.is_address(plan.sender) else None + meta = self._hyperlane_metadata(route) + state = self._hyperlane_protocol_state(route, recipient_bytes32=recipient32, + destination_domain=meta.destination_domain, + native_value_atomic=0, amount_atomic=plan.amount_atomic, + approval_tx_ids=approvals, sender=sender) + transaction_id = (checkpoint.source or {}).get("transactionId") + if not transaction_id: + if not approvals: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted transaction") + pending = Receipt(id=approvals[-1], protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state) + observed = self.conn.get_receipt(approvals[-1]) + if observed is None: + if required: + raise BridgeError("Cannot safely resume Hyperlane because no confirmed approval block is available " + "for source history verification") + return pending + if int(observed["status"]) == 0: + return self._failed(pending, "sourceError", f"EVM approval transaction reverted: {approvals[-1]}") + recovered = self._recover_hyperlane_from_history(route, recipient32, pending, approvals, required=required) + return recovered or pending.replace(status=Status.SOURCE_SUBMISSION_PENDING) + transaction_id = self._require_hash(transaction_id, "source transaction id") + pending = Receipt(id=transaction_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=transaction_id, protocol_state=state) + observed = self._hyperlane_source_status(route, plan, pending) + if observed is not pending: + return observed + return self._recover_hyperlane_from_history(route, recipient32, pending, approvals, required=False) or observed + + def _recover_xreserve_from_history(self, route: Route, plan: Plan, q: _XReserveQuote, owner: str, approvals: list[str], + *, required: bool) -> Receipt | None: + """Scan xReserve logs after the last confirmed approval; a candidate matches only if every event field matches.""" + Web3 = _web3().Web3 + from_block = self._approval_scan_block(approvals) + if from_block is None: + if required: + raise BridgeError("Cannot safely resume xReserve because no confirmed approval block is available " + "for source history verification") + return None + hashes: list[str] = [] + for log in self.conn.w3.eth.get_logs({"address": q.xreserve_contract, "fromBlock": from_block}): + tx_hash = Web3.to_hex(log["transactionHash"]) + if tx_hash not in hashes: + hashes.append(tx_hash) + matches: list[Receipt] = [] + for tx_hash in hashes: + observed = self.conn.get_receipt(tx_hash) + if observed is None: + continue + try: + matches.append(self._confirmed_deposit_receipt(route, q, owner=owner, approval_tx_ids=approvals, source_tx_id=tx_hash, + receipt=observed, mint_mode=plan.mint_mode, intended_recipient=plan.recipient)) + except BridgeError: + continue # other accounts' deposits share the contract; unrelated unless every field matches + if len(matches) > 1: + raise BridgeError("Multiple matching xReserve deposits were found; recovery cannot safely choose one source transaction") + return matches[0] if matches else None + + def _recover_xreserve(self, route: Route, plan: Plan, checkpoint: Checkpoint, *, required: bool) -> Receipt: + Web3 = _web3().Web3 + owner = self._observed_owner(plan, None) + approvals = self._checkpoint_approvals(checkpoint) + source = checkpoint.source or {} + stored_hook = source.get("hookData") + if stored_hook is not None and (not isinstance(stored_hook, str) or not re.fullmatch(r"0x[0-9a-fA-F]{130}", stored_hook)): + raise CheckpointInvalidError("Bridge checkpoint contains invalid xReserve hook data") + hook = bytes.fromhex(stored_hook[2:]) if stored_hook else encoding.xreserve_hook_data( + plan.mint_mode, plan.recipient, self.network, "0scalar") + meta = self._xreserve_metadata(route) # reuse the registry validator rather than trusting raw metadata + token = self.registry.asset(route.source_asset_id).locator + if token is None or token.kind != "evm-contract": + raise RouteUnavailableError(f"xReserve source token contract is missing: {route.id}") + q = _XReserveQuote( + xreserve_contract=meta.xreserve_contract, token=Web3.to_checksum_address(token.value), + source_chain_id=meta.source_chain_id, source_domain=meta.source_domain, remote_domain=meta.remote_domain, + remote_token_bytes32=meta.remote_token_bytes32, + remote_recipient_bytes32=self._xreserve_recipient_bytes32(route, meta, plan.recipient, plan.mint_mode), + amount_atomic=plan.amount_atomic, max_fee_atomic=meta.max_fee_atomic, hook_data=hook, + balance_atomic=0, allowance_atomic=0, bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) + state = self._xreserve_protocol_state(route, q, approval_tx_ids=approvals, sender=owner, mint_mode=plan.mint_mode, + intended_recipient=plan.recipient) + transaction_id = source.get("transactionId") + if not transaction_id: + if not approvals: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted transaction") + pending = Receipt(id=approvals[-1], protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state) + observed = self.conn.get_receipt(approvals[-1]) + if observed is None: + if required: + raise BridgeError("Cannot safely resume xReserve because no confirmed approval block is available " + "for source history verification") + return pending + if int(observed["status"]) == 0: + return self._failed(pending, "sourceError", f"EVM approval transaction reverted: {approvals[-1]}") + recovered = self._recover_xreserve_from_history(route, plan, q, owner, approvals, required=required) + return recovered or pending.replace(status=Status.SOURCE_SUBMISSION_PENDING) + transaction_id = self._require_hash(transaction_id, "xReserve source transaction id") + pending = Receipt(id=transaction_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=transaction_id, protocol_state=state) + observed = self._xreserve_source_status(route, plan, pending) + if observed is not pending: + return observed + return self._recover_xreserve_from_history(route, plan, q, owner, approvals, required=False) or observed + + def recover_source(self, plan: Plan, checkpoint: Checkpoint, *, required: bool = False) -> Receipt: + """Reconstruct an interrupted Ethereum source leg from a checkpoint without signing (brief §2.7, §3.1, §3.2). + + Approval-only checkpoints: observe the last approval; when confirmed, scan the router / + xReserve logs from its block for a matching dispatch or deposit and stop at + ``SOURCE_SUBMISSION_PENDING`` when none exists — recovery never moves funds. Checkpoints + with a source transaction are observed through ``source_status``. ``required=True`` (plan + 4's resume-before-dispatch mode) demands the scan actually run — a known sender and a + confirmed approval block — or raises, instead of quietly returning an approval-boundary + receipt. + """ + if checkpoint.version != 1 or checkpoint.intent.get("bridgeProtocol") != plan.protocol or checkpoint.route.get("id") != plan.route_id: + raise CheckpointInvalidError("Bridge checkpoint does not match the prepared route") + if checkpoint.route.get("registryVersion") != self.registry.version: + raise RegistryVersionMismatchError( + f"Checkpoint uses registry {checkpoint.route.get('registryVersion')}; this client has {self.registry.version}") + if plan.protocol == "hyperlane" and checkpoint.destination: + raise CheckpointInvalidError("Hyperlane checkpoints must not carry a destination leg") + route = self._route_for_plan(plan) + self.assert_chain(route) + if route.protocol == "hyperlane": + return self._recover_hyperlane(route, plan, checkpoint, required=required) + if route.protocol == "xreserve": + return self._recover_xreserve(route, plan, checkpoint, required=required) + raise UnsupportedRouteError(f"No Ethereum recovery for protocol {route.protocol}") + def _mailbox_address(self) -> str: for route in self.registry.routes(protocol="hyperlane", include_unavailable=True, environment=self.bridge.environment): chains = {self.registry.asset(route.source_asset_id).chain_id, self.registry.asset(route.destination_asset_id).chain_id} diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index dea1f7da..99390d6a 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -142,6 +142,13 @@ def add_receipt(self, tx_hash: str, *, status: int = 1, logs: list[dict] | None "cumulativeGasUsed": "0x1", "gasUsed": "0x1", "effectiveGasPrice": "0x1", "type": "0x2", "contractAddress": None, "logsBloom": "0x" + "00" * 256, "logs": logs or []} + def add_transaction(self, tx_hash: str, *, sender: str, to: str, block_number: int = 0x65) -> None: + """Serve eth_getTransactionByHash for a hash the fake never accepted itself.""" + self.transactions[tx_hash] = { + "hash": tx_hash, "from": to_checksum_address(sender), "to": to_checksum_address(to), "input": "0x", "value": "0x0", + "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, "nonce": "0x0", "gas": "0x1", "gasPrice": "0x1", + "transactionIndex": "0x0", "type": "0x2", "chainId": _hex(self.chain_id), "v": "0x0", "r": "0x0", "s": "0x0"} + def make_request(self, method: str, params: Any) -> dict: self.methods.append(method) if method == "eth_chainId": diff --git a/bridge-sdk/tests/test_eth_recover.py b/bridge-sdk/tests/test_eth_recover.py new file mode 100644 index 00000000..6f6c5d43 --- /dev/null +++ b/bridge-sdk/tests/test_eth_recover.py @@ -0,0 +1,215 @@ +import dataclasses + +import pytest +from eth_account import Account +from web3 import Web3 + +from aleo_bridge import encoding +from aleo_bridge.checkpoint import create_checkpoint +from aleo_bridge.errors import BridgeError, CheckpointInvalidError, RegistryVersionMismatchError +from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_web3 import deposited_log, dispatch_id_log, fake_web3, make_bridge, sent_transfer_remote_log + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +OTHER = "0x0000000000000000000000000000000000000009" +ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +ALEO32 = encoding.aleo_address_to_bytes32(ALEO) +WBTC_ROUTER = "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" +MAILBOX = "0xc005dc82818d67AF737725bD4bf75435d065D239" +SEPOLIA_USDC, SEPOLIA_XRESERVE = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "0x008888878f94C0d87defdf0B07f46B93C1934442" +REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") +APPROVAL, DISPATCH = "0x" + "11" * 32, "0x" + "22" * 32 +RECOVERED, RECOVERED_2, EARLIER = "0x" + "44" * 32, "0x" + "55" * 32, "0x" + "66" * 32 +RECOVERED_MESSAGE_ID = bytes.fromhex("cd" * 32) +WBTC_ROUTE = DEFAULT_REGISTRY.route("hyperlane:ethereum/wbtc->aleo/wbtc") +USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:sepolia/usdc->aleo-testnet/usdcx") +WBTC_PLAN = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=ACCT.address) + + +def hyperlane_checkpoint(plan=WBTC_PLAN, *, tx_id=None): + state = {"routeId": WBTC_ROUTE.id, "approvalTxIds": [APPROVAL], "sourceSender": plan.sender, + "recipientBytes32": "0x" + ALEO32.hex(), "destinationDomain": 1634493807, "nativeValueAtomic": "50000", "amountAtomic": "100000"} + receipt = (Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, protocol_state=state) + if tx_id else Receipt(id=APPROVAL, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state)) + return create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + + +def xreserve_checkpoint(plan, hook: bytes, *, tx_id=None): + state = {"routeId": USDC_ROUTE.id, "approvalTxIds": [APPROVAL], "sourceSender": ACCT.address, "mintMode": plan.mint_mode, + "intendedRecipient": ALEO, "xReserveContract": SEPOLIA_XRESERVE, "tokenAddress": SEPOLIA_USDC, "sourceChainId": 11155111, + "remoteDomain": 10002, "remoteRecipientBytes32": "0x" + ALEO32.hex(), "hookData": "0x" + hook.hex(), + "amountAtomic": "2000000", "maxFeeAtomic": "100000"} + receipt = (Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, protocol_state=state) + if tx_id else Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, protocol_state=state)) + return create_checkpoint(plan, receipt, DEFAULT_REGISTRY) + + +def mainnet_read_only(): + w3 = fake_web3() + return make_bridge(ethereum=Ethereum(w3=w3)).eth, w3 + + +def dispatch_history(w3, tx_hash, *, sender=ACCT.address, block_number=0x65, amount=100_000): + w3.provider.history_logs.append(sent_transfer_remote_log(WBTC_ROUTER, destination=1634493807, recipient32=ALEO32, + amount=amount, tx_hash=tx_hash, block_number=block_number)) + w3.provider.add_transaction(tx_hash, sender=sender, to=WBTC_ROUTER, block_number=block_number) + w3.provider.add_receipt(tx_hash, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=tx_hash)], + sender=sender, to=WBTC_ROUTER, block_number=block_number) + + +def test_checkpoint_must_match_plan_and_registry(): + eth, _ = mainnet_read_only() + cp = hyperlane_checkpoint() + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, route={**cp.route, "id": "hyperlane:ethereum/eth->aleo/eth"})) + with pytest.raises(RegistryVersionMismatchError): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, route={**cp.route, "registryVersion": "0000-00-00.stale"})) + with pytest.raises(CheckpointInvalidError, match="no submitted transaction"): + eth.recover_source(WBTC_PLAN, dataclasses.replace(cp, source=None)) + + +def test_hyperlane_rejects_checkpoint_with_destination_leg(): + eth, _ = mainnet_read_only() + cp = hyperlane_checkpoint() + bad = dataclasses.replace(cp, destination={"transactionId": "0x" + "77" * 32}) + with pytest.raises(CheckpointInvalidError, match="destination"): + eth.recover_source(WBTC_PLAN, bad) + + +def test_hyperlane_unmined_approval_stays_approval_pending(): + eth, w3 = mainnet_read_only() + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_APPROVAL_PENDING and receipt.id == APPROVAL and receipt.source_tx_id is None + assert receipt.protocol_state["approvalTxIds"] == [APPROVAL] and receipt.protocol_state["sourceSender"] == ACCT.address + assert "eth_getLogs" not in w3.provider.methods + + +def test_hyperlane_confirmed_approval_without_dispatch_stops_at_submission_boundary(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + +def test_hyperlane_scan_finds_the_dispatch_after_the_approval_block(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, EARLIER, block_number=0x10) # before the approval: must be ignored by fromBlock + dispatch_history(w3, RECOVERED, block_number=0x66) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.DELIVERY_PENDING and receipt.source_tx_id == RECOVERED + assert receipt.id == Web3.to_hex(RECOVERED_MESSAGE_ID) == receipt.protocol_state["messageId"] + assert receipt.protocol_state["approvalTxIds"] == [APPROVAL] + + +def test_hyperlane_scan_ignores_other_senders_and_amounts(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, RECOVERED, sender=OTHER) + dispatch_history(w3, RECOVERED_2, amount=99_999) + assert eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()).status == Status.SOURCE_SUBMISSION_PENDING + + +def test_hyperlane_multiple_matches_refuse_to_choose(): + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + dispatch_history(w3, RECOVERED) + dispatch_history(w3, RECOVERED_2) + with pytest.raises(BridgeError, match="Multiple matching Hyperlane dispatches"): + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + + +def test_hyperlane_required_scan_needs_sender_and_confirmed_approval(): + eth, w3 = mainnet_read_only() + with pytest.raises(BridgeError, match="no confirmed approval block"): + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint(), required=True) + plan_no_sender = _plan_for(DEFAULT_REGISTRY, WBTC_ROUTE, amount_atomic=100_000, recipient=ALEO, sender=None) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + with pytest.raises(BridgeError, match="without the source account"): + eth.recover_source(plan_no_sender, hyperlane_checkpoint(plan_no_sender), required=True) + assert eth.recover_source(plan_no_sender, hyperlane_checkpoint(plan_no_sender)).status == Status.SOURCE_SUBMISSION_PENDING + + +def test_hyperlane_saved_dispatch_is_observed_not_resent(): + eth, w3 = mainnet_read_only() + cp = hyperlane_checkpoint(tx_id=DISPATCH) + pending = eth.recover_source(WBTC_PLAN, cp) + assert pending.status == Status.SOURCE_CONFIRMING and pending.source_tx_id == DISPATCH + w3.provider.add_receipt(DISPATCH, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=DISPATCH)], sender=ACCT.address, to=WBTC_ROUTER) + done = eth.recover_source(WBTC_PLAN, cp) + assert done.status == Status.DELIVERY_PENDING and done.id == Web3.to_hex(RECOVERED_MESSAGE_ID) and w3.provider.sent == [] + + +def test_xreserve_scan_recovers_a_confirmed_deposit_from_an_approval_only_checkpoint(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + hook = bytes(65) + cp = xreserve_checkpoint(plan, hook) + assert cp.source["hookData"] == "0x" + hook.hex() + assert eth.recover_source(plan, cp).status == Status.SOURCE_APPROVAL_PENDING + w3.provider.add_receipt(APPROVAL, block_number=0x65) + assert eth.recover_source(plan, cp).status == Status.SOURCE_SUBMISSION_PENDING + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED, log_index=3) + w3.provider.history_logs.append(log) + w3.provider.add_receipt(RECOVERED, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + other = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=OTHER, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED_2, log_index=1) + w3.provider.history_logs.append(other) + w3.provider.add_receipt(RECOVERED_2, logs=[other], sender=OTHER, to=SEPOLIA_XRESERVE) + receipt = eth.recover_source(plan, cp) + assert receipt.status == Status.ATTESTATION_PENDING and receipt.source_tx_id == RECOVERED + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(RECOVERED[2:]), 3) + assert receipt.protocol_state["nonce"] == "0x" + nonce.hex() and receipt.id == receipt.protocol_state["messageHash"] + + +def test_xreserve_multiple_matches_refuse_to_choose(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + hook = bytes(65) + cp = xreserve_checkpoint(plan, hook) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED, log_index=3) + w3.provider.history_logs.append(log) + w3.provider.add_receipt(RECOVERED, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + log2 = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=ALEO32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=RECOVERED_2, log_index=1) + w3.provider.history_logs.append(log2) + w3.provider.add_receipt(RECOVERED_2, logs=[log2], sender=ACCT.address, to=SEPOLIA_XRESERVE) + with pytest.raises(BridgeError, match="Multiple matching xReserve deposits"): + eth.recover_source(plan, cp) + + +def test_xreserve_required_scan_needs_confirmed_approval(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + with pytest.raises(BridgeError, match="no confirmed approval block"): + eth.recover_source(plan, cp, required=True) + + +def test_xreserve_private_recovery_uses_checkpointed_hook_and_wrapper_recipient(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address, mint_mode="private") + hook = encoding.xreserve_hook_data("private", ALEO, "testnet", "7scalar") + wrapper32 = encoding.aleo_address_to_bytes32(encoding.aleo_program_address("shielded_usdcx_wrapper.aleo", "testnet")) + cp = xreserve_checkpoint(plan, hook) + cp = dataclasses.replace(cp, source={**cp.source, "transactionId": DISPATCH}) + log = deposited_log(SEPOLIA_XRESERVE, local_token=SEPOLIA_USDC, depositor=ACCT.address, remote_recipient32=wrapper32, value=2_000_000, + remote_domain=10002, remote_token32=REMOTE_TOKEN, max_fee=100_000, hook_data=hook, tx_hash=DISPATCH, log_index=2) + w3.provider.add_receipt(DISPATCH, logs=[log], sender=ACCT.address, to=SEPOLIA_XRESERVE) + receipt = eth.recover_source(plan, cp) + assert receipt.status == Status.ATTESTATION_PENDING and receipt.protocol_state["hookData"] == "0x" + hook.hex() + assert receipt.protocol_state["remoteRecipientBytes32"] == "0x" + wrapper32.hex() and receipt.protocol_state["mintMode"] == "private" + bad = dataclasses.replace(cp, source={**cp.source, "hookData": "0x02"}) + with pytest.raises(CheckpointInvalidError, match="hook data"): + eth.recover_source(plan, bad) From f3f8c9ddfcebca911b7a0c551e8696f93f1c4a1a Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 16:55:10 -0400 Subject: [PATCH 38/94] feat(bridge-sdk): Bridge.eth, bare Web3 wrapping, EVM env vars in from_env, EVM chain status --- bridge-sdk/python/aleo_bridge/client.py | 57 +++++++------- bridge-sdk/python/aleo_bridge/eth.py | 14 +++- bridge-sdk/tests/test_bridge_eth_wiring.py | 88 ++++++++++++++++++++++ bridge-sdk/tests/test_client.py | 4 +- 4 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 bridge-sdk/tests/test_bridge_eth_wiring.py diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 52c70bc4..bd84c163 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -16,6 +16,7 @@ from ._calls import AleoCall from .errors import ConfigurationError, MissingExtraError +from .eth import Ethereum, EthModule from .freezelist import FreezeList from .hyperlane import HyperlaneModule from .privacy import PrivacyModule @@ -66,16 +67,16 @@ 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.""" - rpc, key = os.environ.get("ETHEREUM_RPC_URL"), os.environ.get("EVM_PRIVATE_KEY") - if not rpc and not key: - return None - if not (rpc and key): - raise ConfigurationError("Set EVM_PRIVATE_KEY and ETHEREUM_RPC_URL together (both or neither)") - try: - from .eth import Ethereum # plan 2 - except ImportError as exc: - raise MissingExtraError("evm", "An Ethereum connection from EVM_PRIVATE_KEY/ETHEREUM_RPC_URL") from exc - return Ethereum(rpc, private_key=key) + return Ethereum.from_env() + + +def _coerce_ethereum(value: Any) -> Ethereum | None: + """Accept an ``Ethereum`` or a bare ``web3.Web3`` (wrapped; signs only via ``eth.default_account``).""" + if value is None or isinstance(value, Ethereum): + return value + if hasattr(value, "eth") and hasattr(value, "provider"): + return Ethereum(w3=value) + raise ConfigurationError("ethereum= must be an aleo_bridge.Ethereum connection or a web3.Web3 instance") def solana_from_env() -> Any: @@ -124,10 +125,10 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir if not self.registry.chains(environment=environment): raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") self.checkpoints = checkpoints - self.ethereum = ethereum + self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) self.solana = solana self.profile: Profile | None = None - self._eth_module: Any = None + self._eth: EthModule | None = None self._sol_module: Any = None self._programs: dict[str, Any] = {} self.hyperlane = HyperlaneModule(self) @@ -138,19 +139,15 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir def __repr__(self) -> str: return f"Bridge(environment={self.environment!r}, registry={self.registry.version!r})" - # ── side-chain namespaces (plans 2/3 supply the modules) ── + # ── side-chain namespaces (plan 3 supplies the Solana module) ── @property - def eth(self) -> Any: + def eth(self) -> EthModule: + """Ethereum-origin actions (Hyperlane transferRemote, xReserve deposit, status, recovery).""" if self.ethereum is None: - raise ConfigurationError("Ethereum is not configured: Bridge(aleo, ethereum=Ethereum(...)) or set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL") - if self._eth_module is None: - try: - from .eth import Ethereum, EthModule # plan 2 - except ImportError as exc: - raise MissingExtraError("evm", "Ethereum-origin bridging") from exc - connection = self.ethereum if isinstance(self.ethereum, Ethereum) else Ethereum(w3=self.ethereum) - self._eth_module = EthModule(self, connection) - return self._eth_module + raise ConfigurationError("Pass ethereum=Ethereum(...) to Bridge(...) or set ETHEREUM_RPC_URL") + if self._eth is None: + self._eth = EthModule(self, self.ethereum) + return self._eth @property def sol(self) -> Any: @@ -248,17 +245,23 @@ def _public_balance(self, asset: Asset, address: str) -> int: value = self.mapping_value(program, BALANCE_MAPPING, address) return parse_uint_literal(value) if value is not None else 0 - def status(self) -> BridgeStatus: - """Read-only re-orientation: addresses and public balances of every registry asset per configured chain. - Plans 2/3 append EVM/Solana ChainStatus entries; plan 4 fills ``pending`` from the checkpoint store.""" + def _aleo_chain_status(self) -> ChainStatus: chain = self.aleo_chain() account = getattr(self.aleo, "default_account", None) address = str(account.address) if account else None balances = {asset.id: (self._public_balance(asset, address) if address else 0) for asset in self.registry.assets(chain=chain.id)} + return ChainStatus(chain.id, address, address is not None, balances) + + def status(self) -> BridgeStatus: + """Read-only re-orientation: addresses and public balances of every registry asset per configured chain. + Plan 3 appends a Solana ChainStatus entry; plan 4 fills ``pending`` from the checkpoint store.""" + chains = [self._aleo_chain_status()] + if self.ethereum is not None: + chains.append(self.eth.chain_status()) pending: list["Progress"] = [] return BridgeStatus(environment=self.environment, registry_version=self.registry.version, - chains=[ChainStatus(chain.id, address, address is not None, balances)], pending=pending) + chains=chains, pending=pending) # ── constructors ── @classmethod diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 64ea85de..69b15bd4 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -19,8 +19,8 @@ InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Asset, Chain, Registry, Route -from .types import (DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, Receipt, Status, - Step) +from .types import (ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, + Receipt, Status, Step) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -1177,5 +1177,15 @@ def balance(self, asset: Any, *, address: str | None = None) -> int: return int(self._erc20(target.locator.value).functions.balanceOf(owner).call()) raise UnsupportedRouteError(f"{target.id} is not an EVM asset") + def chain_status(self) -> ChainStatus: + """Address, signing ability, and atomic balances of every registry asset on this chain (empty when read-only).""" + address = self.conn.address + balances: dict[str, int] = {} + if address is not None: + for asset in self.registry.assets(chain=self.chain.id): + if asset.locator is not None and asset.locator.kind in ("native", "evm-contract"): + balances[asset.id] = self.balance(asset, address=address) + return ChainStatus(chain_id=self.chain.id, address=address, can_sign=self.conn.can_sign, balances=balances) + __all__ = ["Ethereum", "EthModule"] diff --git a/bridge-sdk/tests/test_bridge_eth_wiring.py b/bridge-sdk/tests/test_bridge_eth_wiring.py new file mode 100644 index 00000000..8fe361e4 --- /dev/null +++ b/bridge-sdk/tests/test_bridge_eth_wiring.py @@ -0,0 +1,88 @@ +import pytest +from eth_account import Account +from web3 import HTTPProvider, Web3 + +from aleo_bridge import Bridge, Ethereum, EthModule, EvmCall +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.types import BridgeStatus, ChainStatus +from tests.fakes.fake_web3 import fake_web3, make_bridge + +KEY = "0x" + "11" * 32 +ACCT = Account.from_key(KEY) +WBTC, USDC, USDT = ("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xdAC17F958D2ee523a2206206994597C13D831ec7") + + +def test_package_exports(): + assert EthModule is not None and EvmCall is not None and Ethereum is not None + + +def test_eth_property_requires_a_connection(): + bridge = make_bridge() + assert bridge.ethereum is None + with pytest.raises(ConfigurationError, match=r"Pass ethereum=Ethereum\(\.\.\.\) to Bridge\(\.\.\.\) or set ETHEREUM_RPC_URL"): + bridge.eth + + +def test_eth_property_is_cached_module_bound_to_connection(): + conn = Ethereum(w3=fake_web3(), private_key=KEY) + bridge = make_bridge(ethereum=conn) + assert bridge.ethereum is conn and isinstance(bridge.eth, EthModule) + assert bridge.eth is bridge.eth and bridge.eth.conn is conn and bridge.eth.chain.id == "ethereum" + assert make_bridge(environment="testnet", ethereum=Ethereum(w3=fake_web3(chain_id=11155111))).eth.chain.id == "sepolia" + + +def test_bare_web3_is_wrapped_read_only_unless_default_account(): + w3 = fake_web3() + bridge = make_bridge(ethereum=w3) + assert isinstance(bridge.ethereum, Ethereum) and bridge.ethereum.w3 is w3 and not bridge.ethereum.can_sign + w3.eth.default_account = ACCT.address + assert make_bridge(ethereum=w3).ethereum.address == ACCT.address + with pytest.raises(ConfigurationError, match="Ethereum connection or a web3.Web3"): + make_bridge(ethereum="https://not-a-client") + + +def test_from_env_requires_both_evm_variables(monkeypatch): + from aleo import PrivateKey + + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", str(PrivateKey.random())) + monkeypatch.delenv("SOLANA_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_CHECKPOINT_DIR", raising=False) + monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) + monkeypatch.delenv("ETHEREUM_RPC_URL", raising=False) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Bridge.from_env() + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.setenv("ETHEREUM_RPC_URL", "http://127.0.0.1:1") + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Bridge.from_env() + monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) + bridge = Bridge.from_env() + assert bridge.ethereum.address == ACCT.address and isinstance(bridge.ethereum.w3.provider, HTTPProvider) + assert bridge.ethereum.w3.provider.endpoint_uri == "http://127.0.0.1:1" + override = Ethereum(w3=fake_web3()) + assert Bridge.from_env(ethereum=override).ethereum is override + monkeypatch.delenv("EVM_PRIVATE_KEY") + monkeypatch.delenv("ETHEREUM_RPC_URL") + assert Bridge.from_env().ethereum is None + + +def test_chain_status_reads_native_and_erc20_balances(): + w3 = fake_web3(eth_balances={ACCT.address: 5}, token_balances={(WBTC, ACCT.address): 7, (USDC, ACCT.address): 2_000_000}) + eth = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)).eth + status = eth.chain_status() + assert isinstance(status, ChainStatus) and status.chain_id == "ethereum" and status.address == ACCT.address and status.can_sign + assert status.balances == {"ethereum/eth": 5, "ethereum/usdc": 2_000_000, "ethereum/wbtc": 7, "ethereum/usdt": 0} + read_only = make_bridge(ethereum=Ethereum(w3=fake_web3())).eth.chain_status() + assert read_only.address is None and not read_only.can_sign and read_only.balances == {} + + +def test_bridge_status_includes_evm_chain(monkeypatch): + aleo_status = ChainStatus(chain_id="aleo", address="aleo1" + "q" * 58, can_sign=True, balances={}) + monkeypatch.setattr(Bridge, "_aleo_chain_status", lambda self: aleo_status) + w3 = fake_web3(eth_balances={ACCT.address: 5}) + bridge = make_bridge(ethereum=Ethereum(w3=w3, private_key=KEY)) + status = bridge.status() + assert isinstance(status, BridgeStatus) and [c.chain_id for c in status.chains] == ["aleo", "ethereum"] + assert status.chains[1].balances["ethereum/eth"] == 5 + assert [c.chain_id for c in make_bridge().status().chains] == ["aleo"] diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 4a50b7d4..c19b8911 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -141,7 +141,7 @@ def test_from_env_side_chain_variables(monkeypatch, tmp_path): for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("EVM_PRIVATE_KEY", "0x" + "11" * 32) - with pytest.raises(ConfigurationError, match="both or neither"): + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): Bridge.from_env() monkeypatch.setenv("ETHEREUM_RPC_URL", "https://eth.example") bridge = Bridge.from_env() # plan 2: real Ethereum connection now constructed @@ -187,7 +187,7 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None assert isinstance(bridge.checkpoints, FileCheckpointStore) # plan 4 binds FileCheckpointStore(profile.checkpoint_dir) assert bridge.checkpoints.directory == bridge.profile.checkpoint_dir assert bridge.profile.checkpoint_dir.is_dir() - marker = object() + marker = Ethereum(w3=fake_web3()) assert Bridge.from_profile(ethereum=marker).ethereum is marker From ed308c6eeef9f3e70459a2a47b7461fb6eb1bd54 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:11:41 -0400 Subject: [PATCH 39/94] test(bridge-sdk): live Ethereum read checks and gated Sepolia USDC leg-1 deposit --- bridge-sdk/tests/live/test_eth_reads.py | 103 ++++++++++++++++++ .../tests/live/test_eth_sepolia_leg1.py | 73 +++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 bridge-sdk/tests/live/test_eth_reads.py create mode 100644 bridge-sdk/tests/live/test_eth_sepolia_leg1.py diff --git a/bridge-sdk/tests/live/test_eth_reads.py b/bridge-sdk/tests/live/test_eth_reads.py new file mode 100644 index 00000000..19698106 --- /dev/null +++ b/bridge-sdk/tests/live/test_eth_reads.py @@ -0,0 +1,103 @@ +"""Read-only mainnet checks: quotes for all three Hyperlane routes, the xReserve deposit quote, +Mailbox.delivered, and a pinned-address balance read. + +Gated by BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL. Nothing here signs or needs a key — the +``Ethereum`` connection is built with no ``private_key``/``signer``, and the ``Bridge`` is built +over a real (keyless) ``aleo.Aleo`` facade so only the ``bridge.eth`` surface is exercised. + +A public RPC's rate limiting (HTTP 429) or a transient 5xx is not a bug in this SDK, so those are +skips, not failures. +""" +import os + +import pytest +import requests + +from aleo_bridge.errors import InsufficientBalanceError +from aleo_bridge.eth import Ethereum + +pytestmark = pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_READS") != "1" or not os.environ.get("ETHEREUM_RPC_URL"), + reason="set BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL") + +ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +# The xReserve contract custodies deposited USDC, so its balance exceeds the 2 USDC minimum and its +# allowance reads are meaningful. Any funded mainnet address works; keep it read-only. +PINNED_SENDER = "0x8888888199b2Df864bf678259607d6D5EBb4e3Ce" +KNOWN_MESSAGE_ID = "0xc7c2c763ef846ff1583d9222d8ecbfc56da2e0cdcc9a63bc4bde51467644794d" # delivered on Aleo + + +def _run(fn): + """Run *fn*; a 429/5xx from the public RPC is an environment condition, not a test failure.""" + try: + return fn() + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status == 429 or (status is not None and status >= 500): + pytest.skip(f"public RPC rate-limited (HTTP {status})") + raise + except requests.exceptions.ConnectionError as exc: + pytest.skip(f"public RPC unreachable: {exc}") + + +@pytest.fixture(scope="module") +def eth(): + from aleo import Aleo, HTTPProvider + + from aleo_bridge import Bridge + + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) # no default_account: read-only + bridge = Bridge(aleo, ethereum=Ethereum(os.environ["ETHEREUM_RPC_URL"])) + return bridge.eth + + +def test_connection_is_mainnet_and_read_only(eth): + chain_id = _run(lambda: eth.conn.chain_id) + assert chain_id == 1 and not eth.conn.can_sign and eth.chain.id == "ethereum" + + +@pytest.mark.parametrize("asset,amount_atomic,router_type", [("eth", 1, "native"), ("wbtc", 1, "collateral"), ("usdt", 1, "collateral")]) +def test_hyperlane_quotes_at_minimum_amounts(eth, asset, amount_atomic, router_type, record_property): + q = _run(lambda: eth.quote_transfer_remote(asset, ALEO_RECIPIENT, amount_atomic=amount_atomic, sender=PINNED_SENDER)) + record_property(f"hyperlane_quote_{asset}", { + "native_fee_atomic": q.native_fee_atomic, "native_value_atomic": q.native_value_atomic, + "approval_required": q.approval_required}) + assert q.native_fee_atomic > 0 and q.plan.amount_atomic == amount_atomic + if router_type == "native": + assert q.native_value_atomic == amount_atomic + q.native_fee_atomic and q.approval_required is None + else: + assert q.native_value_atomic == q.native_fee_atomic and isinstance(q.approval_required, bool) + assert q.fees[0].asset_id == "ethereum/eth" and q.fees[0].estimated + + +def test_xreserve_deposit_quote_read_only(eth, record_property): + try: + q = _run(lambda: eth.quote_deposit_usdc(ALEO_RECIPIENT, amount="2", sender=PINNED_SENDER)) + except InsufficientBalanceError as exc: + # An accepted outcome (controller ruling): PINNED_SENDER's on-chain USDC balance may have + # moved since this test was written. The exception itself proves the balance-check mechanics + # ran (the SDK read PINNED_SENDER's USDC balance before building a quote). + record_property("xreserve_quote_outcome", f"InsufficientBalanceError: {exc}") + return + record_property("xreserve_quote_outcome", { + "route_id": q.plan.route_id, "max_fee_atomic": q.max_fee_atomic, "balance_atomic": q.balance_atomic}) + assert q.plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and q.max_fee_atomic == 100_000 + assert q.hook_data == bytes(65) and len(q.remote_recipient_bytes32) == 32 and q.balance_atomic >= 2_000_000 + + +def test_mailbox_delivered_reads(eth, record_property): + delivered = _run(lambda: eth.is_delivered(KNOWN_MESSAGE_ID)) + record_property("mailbox_delivered_known_id", delivered) + assert isinstance(delivered, bool) + assert _run(lambda: eth.is_delivered("0x" + "00" * 32)) is False + + +def test_balance_pinned_sender(eth, record_property): + value = _run(lambda: eth.balance("ethereum/eth", address=PINNED_SENDER)) + record_property("eth_balance_pinned_sender", value) + assert isinstance(value, int) and value >= 0 + + +def test_chain_status_read_only(eth): + status = _run(lambda: eth.chain_status()) + assert status.chain_id == "ethereum" and status.address is None and status.balances == {} diff --git a/bridge-sdk/tests/live/test_eth_sepolia_leg1.py b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py new file mode 100644 index 00000000..fdc3ba8e --- /dev/null +++ b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py @@ -0,0 +1,73 @@ +"""Leg 1 of the acceptance matrix: 2 USDC Sepolia -> USDCx aleo-testnet, public mint. + +Moves testnet funds. Gated by BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, +EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY. Every checkpoint and the final receipt are written to +BRIDGE_LIVE_STATE_DIR (outside the repo) so plan 4's rehearsal runner can `recover`/`wait` and +run leg 2 from them. Keys are read from the environment and never written or printed. +""" +import json +import os +import time +from pathlib import Path + +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.eth import Ethereum +from aleo_bridge.types import Status + +REQUIRED = ("BRIDGE_LIVE_STATE_DIR", "SEPOLIA_RPC_URL", "EVM_PRIVATE_KEY", "ALEO_E2E_PRIVATE_KEY") +pytestmark = pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_FUNDS") != "1" or any(not os.environ.get(v) for v in REQUIRED), + reason="set BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY") + + +def _bridge(): + from aleo import Aleo, HTTPProvider + + from aleo_bridge import Bridge + + aleo = Aleo(HTTPProvider(os.environ.get("ALEO_ENDPOINT", "https://edge.provable.com/api"), network="testnet")) + aleo.default_account = aleo.account.from_private_key(os.environ["ALEO_E2E_PRIVATE_KEY"]) + state_dir = Path(os.environ["BRIDGE_LIVE_STATE_DIR"]) + state_dir.mkdir(parents=True, exist_ok=True) + return Bridge(aleo, ethereum=Ethereum(os.environ["SEPOLIA_RPC_URL"], private_key=os.environ["EVM_PRIVATE_KEY"]), + checkpoints=FileCheckpointStore(state_dir / "checkpoints")), state_dir + + +def test_sepolia_usdc_deposit_public_mint(): + bridge, state_dir = _bridge() + eth = bridge.eth + assert eth.conn.chain_id == 11155111 and eth.chain.id == "sepolia" + recipient = bridge.aleo_address() + usdc_balance = eth.balance("usdc") + if usdc_balance < 2_000_000: + pytest.fail(f"{eth.conn.address} holds {usdc_balance} µUSDC on Sepolia; leg 1 needs 2 USDC plus ETH for gas") + quote = eth.quote_deposit_usdc(recipient, amount="2", mint_mode="public") + assert quote.plan.route_id == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and quote.plan.recipient == recipient + + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + checkpoint_path = state_dir / f"leg1-sepolia-usdc-{stamp}.checkpoint.json" + receipt_path = state_dir / f"leg1-sepolia-usdc-{stamp}.receipt.json" + + def save(checkpoint): + checkpoint_path.write_text(checkpoint.to_json()) # latest boundary wins; the store keeps every id + + result = eth.deposit_usdc(recipient, amount="2", mint_mode="public").send( + on_checkpoint=save, timeout_seconds=240.0, poll_seconds=3.0) + receipt = result.receipt + receipt_path.write_text(json.dumps({ + "leg": 1, "route_id": result.route_id, "status": receipt.status.value, "receipt_id": receipt.id, + "source_tx_id": receipt.source_tx_id, "message_hash": result.message_hash, "nonce": result.nonce, + "approval_tx_ids": receipt.protocol_state["approvalTxIds"], "recipient": recipient, "sender": eth.conn.address, + "plan": quote.plan.to_dict(), "protocol_state": receipt.protocol_state, + }, indent=2)) + assert checkpoint_path.exists() and bridge.checkpoints.load(receipt.id) is not None + assert receipt.status in (Status.ATTESTATION_PENDING, Status.SOURCE_CONFIRMING, Status.SOURCE_APPROVAL_PENDING) + if receipt.status == Status.ATTESTATION_PENDING: + assert len(result.message_hash) == 66 and len(result.nonce) == 66 + assert receipt.protocol_state["depositLogIndex"] >= 0 and receipt.protocol_state["mintMode"] == "public" + else: + # A timeout is not a failure: the receipt and checkpoint carry the hashes for recover()/source_status(). + refreshed = eth.source_status(quote.plan, receipt) + assert refreshed.status != Status.FAILED From c33f2a3834ade9f3847bfe670b8f274ae967e99b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:11:46 -0400 Subject: [PATCH 40/94] docs(bridge-sdk): document bridge.eth in the README Ethereum section --- bridge-sdk/README.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 92e45ecd..4bd4706c 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -28,8 +28,40 @@ the web3.py-style verb structure of `aleo-sdk`. bridge.shield("aleo/eth", amount="0.01"); bridge.unshield("aleo/usdcx", amount="2.5") Reads return values; writes return an `AleoCall` with `simulate() / prove() / transact() / delegate()`. -Lifecycle verbs (`quote → execute → wait`, `recover/resume/complete`), Ethereum and Solana origins, -and the agent/MCP surface arrive in the following plans. +Lifecycle verbs (`quote → execute → wait`, `recover/resume/complete`), Solana origins, and the +agent/MCP surface arrive in the following plans. + +## Ethereum + + from web3 import Web3 + from aleo_bridge import Bridge, Ethereum + + bridge = Bridge(aleo, ethereum=Ethereum("https://eth.example/rpc", private_key=evm_key)) # SDK-built transport + bridge = Bridge(aleo, ethereum=Ethereum(w3=my_w3, signer=my_local_account)) # your Web3 + your signer + bridge = Bridge(aleo, ethereum=my_w3) # bare Web3: read-only, or signs via w3.eth.default_account middleware + bridge = Bridge.from_env() # EVM_PRIVATE_KEY + ETHEREUM_RPC_URL (both or neither) + + quote = bridge.eth.quote_transfer_remote("wbtc", aleo_recipient, amount="0.001") + print(quote.native_fee_atomic, quote.approval_required) + + call = bridge.eth.transfer_remote("wbtc", aleo_recipient, amount="0.001") + call.build() # unsigned tx dicts: approve(s) then transferRemote + result = call.send(on_checkpoint=store.save) # approvals → dispatch; each hash checkpointed before polling + result.message_id, result.receipt.status # Hyperlane message id, DELIVERY_PENDING + + deposit = bridge.eth.deposit_usdc(aleo_recipient, amount="2", mint_mode="public").send() + deposit.message_hash # Circle attestation lookup key (receipt id), ATTESTATION_PENDING + + bridge.eth.balance("eth"); bridge.eth.is_delivered(message_id) # reads + bridge.eth.source_status(plan, receipt) # one refresh of an approval/confirming receipt + bridge.eth.recover_source(plan, checkpoint) # log-scan recovery, never signs + +Routes: ETH (native), WBTC and USDT (collateral; USDT resets a non-zero allowance to 0 first) via Hyperlane; +USDC → USDCx via Circle xReserve (2 USDC minimum, `mint_mode` public/record/private — private deposits go to the +shielded wrapper program and need the same `secret_nonce` at `complete` time; the SDK never stores it). +A receipt timeout returns a pending receipt, never a failure. Live checks: `BRIDGE_LIVE_READS=1 ETHEREUM_RPC_URL=…` +for read-only mainnet quotes; `BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR=… SEPOLIA_RPC_URL=… EVM_PRIVATE_KEY=… +ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg. ## Environment From 1b6756373899d5e453ed47ba8a0b110e81afb128 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:18:51 -0400 Subject: [PATCH 41/94] fix(bridge-sdk): recover_source required semantics, chain-checked status, env aliases --- bridge-sdk/README.md | 7 ++-- bridge-sdk/python/aleo_bridge/client.py | 3 +- bridge-sdk/python/aleo_bridge/eth.py | 40 ++++++++++++++++++---- bridge-sdk/tests/test_bridge_eth_wiring.py | 21 ++++++++++-- bridge-sdk/tests/test_client.py | 9 +++-- bridge-sdk/tests/test_eth_connection.py | 18 ++++++++++ bridge-sdk/tests/test_eth_recover.py | 23 +++++++++++++ 7 files changed, 107 insertions(+), 14 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 4bd4706c..149d91c0 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -39,7 +39,8 @@ agent/MCP surface arrive in the following plans. bridge = Bridge(aleo, ethereum=Ethereum("https://eth.example/rpc", private_key=evm_key)) # SDK-built transport bridge = Bridge(aleo, ethereum=Ethereum(w3=my_w3, signer=my_local_account)) # your Web3 + your signer bridge = Bridge(aleo, ethereum=my_w3) # bare Web3: read-only, or signs via w3.eth.default_account middleware - bridge = Bridge.from_env() # EVM_PRIVATE_KEY + ETHEREUM_RPC_URL (both or neither) + bridge = Bridge.from_env() # EVM_PRIVATE_KEY + ETHEREUM_RPC_URL (both or neither); + # aliases BRIDGE_EVM_PRIVATE_KEY / BRIDGE_LIVE_ETHEREUM_RPC_URL quote = bridge.eth.quote_transfer_remote("wbtc", aleo_recipient, amount="0.001") print(quote.native_fee_atomic, quote.approval_required) @@ -67,7 +68,9 @@ ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg. `BRIDGE_PRIVATE_KEY` (required by `from_env`), `ALEO_ENDPOINT` (default `https://edge.provable.com/api`), `ALEO_NETWORK` (`mainnet`|`testnet`), `ALEO_API_KEY`/`ALEO_CONSUMER_ID` (legacy hosts), -`EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`), `BRIDGE_CHECKPOINT_DIR`. +`EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL` (aliases `BRIDGE_EVM_PRIVATE_KEY`+`BRIDGE_LIVE_ETHEREUM_RPC_URL`, used by the +user's live shell/veil config; the primary variable wins when both are set), `SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`), +`BRIDGE_CHECKPOINT_DIR`. Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo key (mode 600). ## Tests diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index bd84c163..14e0611f 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -144,7 +144,8 @@ def __repr__(self) -> str: def eth(self) -> EthModule: """Ethereum-origin actions (Hyperlane transferRemote, xReserve deposit, status, recovery).""" if self.ethereum is None: - raise ConfigurationError("Pass ethereum=Ethereum(...) to Bridge(...) or set ETHEREUM_RPC_URL") + raise ConfigurationError( + "Pass ethereum=Ethereum(...) to Bridge(...) or set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL") if self._eth is None: self._eth = EthModule(self, self.ethereum) return self._eth diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 69b15bd4..1a641c7b 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -76,12 +76,20 @@ 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`` (both or neither) → signing connection; neither → None. + + Aliases (the user's live shell / veil config export these names instead): + ``BRIDGE_EVM_PRIVATE_KEY`` for the key, ``BRIDGE_LIVE_ETHEREUM_RPC_URL`` for the RPC url. + The primary variable wins when both a primary and its alias are set; the both-or-neither + rule applies to whichever pair resolves (primary, falling back to alias, per variable). + """ env = os.environ if env is None else env - key = env.get("EVM_PRIVATE_KEY") - url = env.get("ETHEREUM_RPC_URL") + key = env.get("EVM_PRIVATE_KEY") or env.get("BRIDGE_EVM_PRIVATE_KEY") + url = env.get("ETHEREUM_RPC_URL") or env.get("BRIDGE_LIVE_ETHEREUM_RPC_URL") if bool(key) != bool(url): - raise ConfigurationError("Set both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL or neither") + raise ConfigurationError( + "Set both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL or neither " + "(aliases: BRIDGE_EVM_PRIVATE_KEY, BRIDGE_LIVE_ETHEREUM_RPC_URL)") if not key: return None return cls(url, private_key=key) @@ -1131,7 +1139,10 @@ def recover_source(self, plan: Plan, checkpoint: Checkpoint, *, required: bool = with a source transaction are observed through ``source_status``. ``required=True`` (plan 4's resume-before-dispatch mode) demands the scan actually run — a known sender and a confirmed approval block — or raises, instead of quietly returning an approval-boundary - receipt. + receipt. ``required=True`` only makes the INABILITY to scan fatal: once the scan actually + runs, a completed scan that matches zero dispatches/deposits is a valid answer ("nothing + was submitted yet"), not an error, and returns ``SOURCE_SUBMISSION_PENDING`` so ``resume`` + may re-authorize the send. """ if checkpoint.version != 1 or checkpoint.intent.get("bridgeProtocol") != plan.protocol or checkpoint.route.get("id") != plan.route_id: raise CheckpointInvalidError("Bridge checkpoint does not match the prepared route") @@ -1177,8 +1188,25 @@ def balance(self, asset: Any, *, address: str | None = None) -> int: return int(self._erc20(target.locator.value).functions.balanceOf(owner).call()) raise UnsupportedRouteError(f"{target.id} is not an EVM asset") + def _chain_assertion_route(self) -> Route: + """Any route originating on this chain with a usable ``sourceChainId``, used only to bind + ``assert_chain`` to the registry's notion of this chain (never touches contracts).""" + routes = [r for r in self.registry.routes(include_unavailable=True, environment=self.bridge.environment) + if self.registry.asset(r.source_asset_id).chain_id == self.chain.id + and isinstance(r.metadata.get("sourceChainId"), int)] + if not routes: + raise UnsupportedRouteError( + f"No Hyperlane or xReserve route with sourceChainId is configured for {self.chain.id}") + return routes[0] + def chain_status(self) -> ChainStatus: - """Address, signing ability, and atomic balances of every registry asset on this chain (empty when read-only).""" + """Address, signing ability, and atomic balances of every registry asset on this chain (empty when read-only). + + Asserts the connected ``Web3``'s ``eth_chainId`` matches this chain's registry + ``sourceChainId`` first, so a connection pointed at the wrong network raises + ``ChainMismatchError`` instead of silently reading balances from the wrong chain. + """ + self.assert_chain(self._chain_assertion_route()) address = self.conn.address balances: dict[str, int] = {} if address is not None: diff --git a/bridge-sdk/tests/test_bridge_eth_wiring.py b/bridge-sdk/tests/test_bridge_eth_wiring.py index 8fe361e4..3582109d 100644 --- a/bridge-sdk/tests/test_bridge_eth_wiring.py +++ b/bridge-sdk/tests/test_bridge_eth_wiring.py @@ -3,7 +3,7 @@ from web3 import HTTPProvider, Web3 from aleo_bridge import Bridge, Ethereum, EthModule, EvmCall -from aleo_bridge.errors import ConfigurationError +from aleo_bridge.errors import ChainMismatchError, ConfigurationError from aleo_bridge.types import BridgeStatus, ChainStatus from tests.fakes.fake_web3 import fake_web3, make_bridge @@ -20,7 +20,8 @@ def test_package_exports(): def test_eth_property_requires_a_connection(): bridge = make_bridge() assert bridge.ethereum is None - with pytest.raises(ConfigurationError, match=r"Pass ethereum=Ethereum\(\.\.\.\) to Bridge\(\.\.\.\) or set ETHEREUM_RPC_URL"): + with pytest.raises(ConfigurationError, + match=r"Pass ethereum=Ethereum\(\.\.\.\) to Bridge\(\.\.\.\) or set EVM_PRIVATE_KEY \+ ETHEREUM_RPC_URL"): bridge.eth @@ -48,6 +49,8 @@ def test_from_env_requires_both_evm_variables(monkeypatch): monkeypatch.setenv("BRIDGE_PRIVATE_KEY", str(PrivateKey.random())) monkeypatch.delenv("SOLANA_PRIVATE_KEY", raising=False) monkeypatch.delenv("BRIDGE_CHECKPOINT_DIR", raising=False) + monkeypatch.delenv("BRIDGE_EVM_PRIVATE_KEY", raising=False) + monkeypatch.delenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", raising=False) monkeypatch.setenv("EVM_PRIVATE_KEY", KEY) monkeypatch.delenv("ETHEREUM_RPC_URL", raising=False) with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): @@ -77,6 +80,20 @@ def test_chain_status_reads_native_and_erc20_balances(): assert read_only.address is None and not read_only.can_sign and read_only.balances == {} +def test_chain_status_asserts_the_connected_chain(monkeypatch): + """A Web3 pointed at the wrong network must fail chain_status() (and therefore Bridge.status()) + with ChainMismatchError before any balance is read.""" + w3 = fake_web3(chain_id=999) + bridge = make_bridge(ethereum=Ethereum(w3=w3)) + with pytest.raises(ChainMismatchError, match="expected 1"): + bridge.eth.chain_status() + assert "eth_getBalance" not in w3.provider.methods and "eth_call" not in w3.provider.methods + aleo_status = ChainStatus(chain_id="aleo", address=None, can_sign=False, balances={}) + monkeypatch.setattr(Bridge, "_aleo_chain_status", lambda self: aleo_status) + with pytest.raises(ChainMismatchError, match="expected 1"): + bridge.status() + + def test_bridge_status_includes_evm_chain(monkeypatch): aleo_status = ChainStatus(chain_id="aleo", address="aleo1" + "q" * 58, can_sign=True, balances={}) monkeypatch.setattr(Bridge, "_aleo_chain_status", lambda self: aleo_status) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index c19b8911..2a357892 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -113,7 +113,8 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) for var in ("BRIDGE_PRIVATE_KEY", "ALEO_ENDPOINT", "ALEO_NETWORK", "ALEO_API_KEY", "ALEO_CONSUMER_ID", "EVM_PRIVATE_KEY", - "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): monkeypatch.delenv(var, raising=False) with pytest.raises(ConfigurationError, match="BRIDGE_PRIVATE_KEY"): Bridge.from_env() @@ -138,7 +139,8 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None def test_from_env_side_chain_variables(monkeypatch, tmp_path): monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *a, **k: FakeAleo(mappings=default_mappings())) monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") - for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_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"): @@ -176,7 +178,8 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None return FakeAleo(mappings=default_mappings(), network_name=network) monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) - for var in ("BRIDGE_PRIVATE_KEY", "BRIDGE_PRIVATE_KEY_FILE", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): + for var in ("BRIDGE_PRIVATE_KEY", "BRIDGE_PRIVATE_KEY_FILE", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", + "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "home")) bridge = Bridge.from_profile(network="testnet", endpoint="https://api.provable.com/v2") diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py index 2da57fb7..79f911e7 100644 --- a/bridge-sdk/tests/test_eth_connection.py +++ b/bridge-sdk/tests/test_eth_connection.py @@ -149,3 +149,21 @@ def test_from_env(): Ethereum.from_env({"ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) conn = Ethereum.from_env({"EVM_PRIVATE_KEY": KEY, "ETHEREUM_RPC_URL": "http://127.0.0.1:1"}) assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" + + +def test_from_env_aliases(monkeypatch): + """``BRIDGE_EVM_PRIVATE_KEY`` / ``BRIDGE_LIVE_ETHEREUM_RPC_URL`` stand in for the primary variables.""" + from aleo_bridge.eth import Ethereum + + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("BRIDGE_EVM_PRIVATE_KEY", KEY) + with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): + Ethereum.from_env() + monkeypatch.setenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", "http://127.0.0.1:1") + conn = Ethereum.from_env() + assert conn is not None and conn.address == ACCT.address and conn.w3.provider.endpoint_uri == "http://127.0.0.1:1" + # the primary variable wins when both a primary and its alias are set + monkeypatch.setenv("ETHEREUM_RPC_URL", "http://127.0.0.1:2") + conn2 = Ethereum.from_env() + assert conn2.w3.provider.endpoint_uri == "http://127.0.0.1:2" diff --git a/bridge-sdk/tests/test_eth_recover.py b/bridge-sdk/tests/test_eth_recover.py index 6f6c5d43..f3a8b3c3 100644 --- a/bridge-sdk/tests/test_eth_recover.py +++ b/bridge-sdk/tests/test_eth_recover.py @@ -134,6 +134,16 @@ def test_hyperlane_required_scan_needs_sender_and_confirmed_approval(): assert eth.recover_source(plan_no_sender, hyperlane_checkpoint(plan_no_sender)).status == Status.SOURCE_SUBMISSION_PENDING +def test_hyperlane_required_scan_with_no_matching_dispatch_is_not_fatal(): + """A completed scan (known sender, confirmed approval) that matches nothing is a valid answer, + not an inability to scan: required=True still returns SOURCE_SUBMISSION_PENDING, never sends.""" + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint(), required=True) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + def test_hyperlane_saved_dispatch_is_observed_not_resent(): eth, w3 = mainnet_read_only() cp = hyperlane_checkpoint(tx_id=DISPATCH) @@ -196,6 +206,19 @@ def test_xreserve_required_scan_needs_confirmed_approval(): eth.recover_source(plan, cp, required=True) +def test_xreserve_required_scan_with_no_matching_deposit_is_not_fatal(): + """Same ruling for xReserve: a completed scan (known sender, confirmed approval) that matches + nothing is a valid answer, not an inability to scan.""" + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + w3.provider.add_receipt(APPROVAL, block_number=0x65) + receipt = eth.recover_source(plan, cp, required=True) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.id == APPROVAL + assert "eth_getLogs" in w3.provider.methods and w3.provider.sent == [] + + def test_xreserve_private_recovery_uses_checkpointed_hook_and_wrapper_recipient(): w3 = fake_web3(chain_id=11155111) eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth From 361ff9124a83dca2a0fe31103f573844735bad39 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:21:54 -0400 Subject: [PATCH 42/94] feat(bridge-sdk): sealevel TransferRemote instruction data with veil's mainnet fixtures --- bridge-sdk/python/aleo_bridge/_sealevel.py | 43 ++++++ bridge-sdk/tests/fakes/sealevel_fixtures.py | 52 ++++++++ .../tests/fixtures/sealevel-igp-account.json | 5 + .../fixtures/sealevel-transfer-remote.json | 122 ++++++++++++++++++ bridge-sdk/tests/test_sealevel_instruction.py | 41 ++++++ 5 files changed, 263 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/_sealevel.py create mode 100644 bridge-sdk/tests/fakes/sealevel_fixtures.py create mode 100644 bridge-sdk/tests/fixtures/sealevel-igp-account.json create mode 100644 bridge-sdk/tests/fixtures/sealevel-transfer-remote.json create mode 100644 bridge-sdk/tests/test_sealevel_instruction.py diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py new file mode 100644 index 00000000..5ba279b3 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -0,0 +1,43 @@ +"""Pure Hyperlane sealevel (Solana) layouts for the SOL warp route. + +Nothing here imports solders or solana-py: inputs and outputs are ``bytes``, +``int`` and base58 ``str`` so every layout is testable on an Aleo-only +install. Sources: veil ``src/solana/SEALEVEL_NOTES.md`` (primary-source +derivations against hyperlane-monorepo 45c0988), ``src/solana/transferRemote.ts``, +``src/solana/igp.ts``, ``src/protocols/hyperlane/solanaMetadata.ts``, and the +recorded mainnet deposit in ``tests/fixtures/sealevel-transfer-remote.json``. +""" +from __future__ import annotations + +from .errors import ConfigurationError, InvalidAmountError, InvalidRecipientError + +# SEALEVEL_NOTES §1: every Sealevel Hyperlane instruction is prefixed with this +# fixed 8-byte discriminator; TransferRemote is Borsh enum variant 1. +PROGRAM_INSTRUCTION_DISCRIMINATOR = bytes([1] * 8) +TRANSFER_REMOTE_VARIANT_TAG = 1 +INSTRUCTION_DATA_BYTES = 77 # 8 + 1 + 4 + 32 + 32 +U256_BYTES = 32 +ALEO_MAINNET_HYPERLANE_DOMAIN = 1634493807 + + +def build_transfer_remote_instruction_data(destination_domain: int, recipient32: bytes, amount: int) -> bytes: + """``[8B 0x01×8][1B 0x01][u32 LE domain][32B recipient][u256 LE amount]`` — 77 bytes. + + ``recipient32`` is the raw bech32m payload from ``encoding.aleo_address_to_bytes32`` + (no byte reversal); ``amount`` is lamports. + """ + if not 0 <= destination_domain <= 0xFFFF_FFFF: + raise ConfigurationError(f"destination domain {destination_domain} does not fit in a u32") + if len(recipient32) != 32: + raise InvalidRecipientError(f"recipient must be exactly 32 bytes, got {len(recipient32)}") + if not 0 <= amount < (1 << (U256_BYTES * 8)): + raise InvalidAmountError("amount does not fit in a 32-byte unsigned integer") + data = ( + PROGRAM_INSTRUCTION_DISCRIMINATOR + + bytes([TRANSFER_REMOTE_VARIANT_TAG]) + + destination_domain.to_bytes(4, "little") + + bytes(recipient32) + + amount.to_bytes(U256_BYTES, "little") + ) + assert len(data) == INSTRUCTION_DATA_BYTES + return data diff --git a/bridge-sdk/tests/fakes/sealevel_fixtures.py b/bridge-sdk/tests/fakes/sealevel_fixtures.py new file mode 100644 index 00000000..3d020087 --- /dev/null +++ b/bridge-sdk/tests/fakes/sealevel_fixtures.py @@ -0,0 +1,52 @@ +"""Golden fixtures from veil's test/fixtures (mainnet TransferRemote + inner IGP account). + +Pure: no solders import, so the _sealevel layout tests run on an Aleo-only install. +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +TRANSFER: dict = json.loads((FIXTURES / "sealevel-transfer-remote.json").read_text()) +IGP: dict = json.loads((FIXTURES / "sealevel-igp-account.json").read_text()) + +WARP_PROGRAM_ADDRESS = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" +ALEO_MAINNET_DOMAIN = 1634493807 +DESTINATION_GAS_AMOUNT = 464_000 +EXPECTED_IGP_PAYMENT_LAMPORTS = 2_900_000 +NETWORK_FEE_LAMPORTS = 10_000 +GAS_PAYMENT_RENT_LAMPORTS = 1_872_240 +DISPATCHED_MESSAGE_RENT_LAMPORTS = 2_241_120 +FEE_PAYER_RENT_LAMPORTS = 890_880 +EXPECTED_MESSAGE_ID = "0xffe0409d00c184769b4dfa2a1eaac5a0a79bfe52458a38e1d9a71a9e5c677805" + + +def igp_account_data() -> bytes: + return base64.b64decode(IGP["dataBase64"]) + + +def metadata_from_fixture(*, overhead: bool = True) -> dict[str, str | int | bool]: + """Route metadata (veil camelCase keys) read off the fixture's ordered account list (SEALEVEL_NOTES §2).""" + accounts = TRANSFER["accounts"] + metadata: dict[str, str | int | bool] = { + "warpProgramAddress": WARP_PROGRAM_ADDRESS, + "tokenPda": accounts[2]["address"], + "nativeCollateralPda": accounts[15]["address"], + "dispatchAuthorityPda": accounts[5]["address"], + "mailboxProgramAddress": accounts[3]["address"], + "mailboxOutboxPda": accounts[4]["address"], + "igpProgramAddress": accounts[9]["address"], + "igpProgramDataPda": accounts[10]["address"], + "igpAccount": accounts[13]["address"], + "splNoopProgramAddress": accounts[1]["address"], + "destinationDomain": ALEO_MAINNET_DOMAIN, + "destinationGasAmount": str(DESTINATION_GAS_AMOUNT), + "registryCommit": "418056e21734d26a7d14692e0ec5e902cc9e86bf", + "solanaReviewedAt": "2026-08-28T00:00:00Z", + "solanaConfigSource": "hyperlane-registry@418056e2:deployments/warp_routes/SOL/aleo-config.yaml", + } + if overhead: + metadata["igpOverheadAccount"] = accounts[12]["address"] + return metadata diff --git a/bridge-sdk/tests/fixtures/sealevel-igp-account.json b/bridge-sdk/tests/fixtures/sealevel-igp-account.json new file mode 100644 index 00000000..d0c1ecf7 --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-igp-account.json @@ -0,0 +1,5 @@ +{ + "address": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "dataBase64": "AUlHUF9fX19f/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYexQI6tUiqLpNeDGtJzI0pBygIJxeztkEnRaP31PGDzh7FAjq1SKouk14Ma0nMjSkHKAgnF7O2QSdFo/fU8YPMqAAAAAQAAAAAuJ3SEEsbS9RMAAAAAAAAAb1OZDAAAAAAAAAAAAAAAABIKAAAAAC4ndIQSxtL1EwAAAAAAAABm4j8FAAAAAAAAAAAAAAAAEjgAAAAArra/JT0xF78GAAAAAAAAAFE/ID4AAAAAAAAAAAAAAAASggAAAAAuJ3SEEsbS9RMAAAAAAAAAZuI/BQAAAAAAAAAAAAAAABKJAAAAAFDOP1FrNzkAAAAAAAAAAAD3+RHS1AEAAAAAAAAAAAAAEo8AAAAA/tlRMWbrDwAAAAAAAAAAAFiBzfhTGgAAAAAAAAAAAAASrQAAAAD87P+Pgp/tAgAAAAAAAAAASRyEIo8AAAAAAAAAAAAAABJxAQAAAHrxgJdMAQAAAAAAAAAAAAAMf+pD85tCAQAAAAAAAAAAEuABAAAALid0hBLG0vUTAAAAAAAAAA6L/xQAAAAAAAAAAAAAAAASxAMAAACWmfc66Ofk3gIAAAAAAAAAAOQLVAIAAAAAAAAAAAAAABLnAwAAAN3ZVj/lDk/RAAAAAAAAAABRSqAAAgAAAAAAAAAAAAAAErUKAAAALid0hBLG0vUTAAAAAAAAAK3y3QsAAAAAAAAAAAAAAAASEhAAAADrjW+d/ma059sCAAAAAAAAkJmSAAAAAAAAAAAAAAAAABLSFAAAAC4ndIQSxtL1EwAAAAAAAAAOi/8UAAAAAAAAAAAAAAAAEvMdAAAAGpTTD9tAAAAAAAAAAAAAAKdz5vFldgYAAAAAAAAAAAASBSEAAAAuJ3SEEsbS9RMAAAAAAAAAZuI/BQAAAAAAAAAAAAAAABIRJgAAAE34OyEArk4AAAAAAAAAAABR4Km4UwUAAAAAAAAAAAAAEiVeAAAAmcZL5bU/AgAAAAAAAAAAAEfUBJxfugAAAAAAAAAAAAASE2MAAAAuJ3SEEsbS9RMAAAAAAAAADov/FAAAAAAAAAAAAAAAABJzgQAAAFPLhMCJpWgAAAAAAAAAAACDQxZUAQQAAAAAAAAAAAAAEouGAAAALid0hBLG0vUTAAAAAAAAAA6L/xQAAAAAAAAAAAAAAAASsZcAAADZmVSdxSUWAAAAAAAAAAAA1RsYp+wSAAAAAAAAAAAAABLjoQAAAPzs/4+Cn+0CAAAAAAAAAABJHIQijwAAAAAAAAAAAAAAErGkAAAALid0hBLG0vUTAAAAAAAAADb+cgkAAAAAAAAAAAAAAAASaqgAAACzdyh4RQBqEwAAAAAAAAAAuFDGlhUAAAAAAAAAAAAAABK4wwAAAPdJMvJaBAQAAAAAAAAAAAD660lY9zoAAAAAAAAAAAAAEi7LAAAA0oEZOMajAAAAAAAAAAAAANixzhcnjwIAAAAAAAAAAAAS8d4AAAAuJ3SEEsbS9RMAAAAAAAAADov/FAAAAAAAAAAAAAAAABII5wAAAC4ndIQSxtL1EwAAAAAAAAAOi/8UAAAAAAAAAAAAAAAAEmCsAgAAnKOeGxC+AwAAAAAAAAAAAFktF27+bwAAAAAAAAAAAAASK1wJAACrKd/7z2QDAAAAAAAAAAAATAUuqH97AAAAAAAAAAAAABLSZwsAAC4ndIQSxtL1EwAAAAAAAACWxgwBAAAAAAAAAAAAAAAAEn8V/AIAodtmGJSxrQAAAAAAAAAAAD8GAAAAAAAAAAAAAAAAAAAJHoRlFQB3Lul3g54ZAAAAAAAAAAAAvluDugYAAAAAAAAAAAAAABJ7gToeAADAKfc9VAUAAAAAAAAAAACDywAAAAAAAAAAAAAAAAAACTLPox4A/Oz/j4Kf7QIAAAAAAAAAAGho3joAAAAAAAAAAAAAAAAS3FNmKwAFPZ1qitLsAAAAAAAAAAAAZAAAAAAAAAAAAAAAAAAAAAZFTEVDANFEje1xBwAAAAAAAAAAAACoFAAAAAAAAAAAAAAAAAAABr2Q+VMA8TQm+7zPggAAAAAAAAAAAIQdAAAAAAAAAAAAAAAAAAAJXGpkYQA0fpEe/NAAAAAAAAAAAAAAx/twl6MDAAAAAAAAAAAAABJvZWxhAGCoIAWvAAAAAAAAAAAAAADhRAEAAAAAAAAAAAAAAAAABnhsb3MAe7XqTBcGAAAAAAAAAAAAAJUtAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "capturedAtSlot": 442410490 +} diff --git a/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json b/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json new file mode 100644 index 00000000..37dbfa11 --- /dev/null +++ b/bridge-sdk/tests/fixtures/sealevel-transfer-remote.json @@ -0,0 +1,122 @@ +{ + "signature": "cWFKiumuvVuvrxM8xtunZxNM4FNUppSdyNm7HEqKjV3ZmENebD4DAf44kbyvq9fKJ61VzNrH3tYpLJgUrY8MEGW", + "slot": 442407364, + "instructionDataBase64": "AQEBAQEBAQEBb2VsYRw0lpkefGEc7V7lzQze6WnFPvyKVJeuBQgZse8A7SkSACqpcJ0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "accounts": [ + { + "address": "11111111111111111111111111111111", + "signer": false, + "writable": false + }, + { + "address": "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", + "signer": false, + "writable": false + }, + { + "address": "JDkpV5CsSbhyGhHhirC5DjGPTcuKWUVHtBZ5MFsgu3ZW", + "signer": false, + "writable": false + }, + { + "address": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", + "signer": false, + "writable": false + }, + { + "address": "BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "signer": false, + "writable": true + }, + { + "address": "ATDttjggAZKyS19kcV6Rn56oMi49gDprZGckRou9vkkY", + "signer": false, + "writable": false + }, + { + "address": "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC", + "signer": true, + "writable": true + }, + { + "address": "7H2KAwXsrVWoAhY9ff1nNanYbp4amnF2mZdwzJDi9AhF", + "signer": true, + "writable": false + }, + { + "address": "GttQDgYR9gVLvjMofpVBiU6BgrJp5V7DFWfap6oLV6WY", + "signer": false, + "writable": true + }, + { + "address": "BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv", + "signer": false, + "writable": false + }, + { + "address": "8Cv4PHJ6Cf3xY7dse7wYeZKtuQv9SAN6ujt5w22a2uho", + "signer": false, + "writable": true + }, + { + "address": "3hWynyfaw9gZxa7ik2vWfYGLp94Vcge3jb7Xa5qZ84k1", + "signer": false, + "writable": true + }, + { + "address": "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF", + "signer": false, + "writable": false + }, + { + "address": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", + "signer": false, + "writable": true + }, + { + "address": "11111111111111111111111111111111", + "signer": false, + "writable": false + }, + { + "address": "8HY3hxmnrWwqEmcdwkSnfN9wEQFUkyiwZvU1vMbnXgbC", + "signer": false, + "writable": true + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 invoke [1]", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi invoke [2]", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Protocol fee of 0 paid from 4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC to BvZpTuYLAR77mPhH4GtvwEWUTs53GQqkgBNuXpCePVNk", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Dispatched message to 1634493807, ID 0xffe0409d00c184769b4dfa2a1eaac5a0a79bfe52458a38e1d9a71a9e5c677805", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi consumed 84675 of 980334 compute units", + "Program return: E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi /+BAnQDBhHabTfoqHqrFoKeb/lJFijjh2acanlxneAU=", + "Program E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi success", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv invoke [2]", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program log: Paid IGP JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M for 464000 gas for message 0xffe0\u20267805 to 1634493807", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv consumed 105639 of 892647 compute units", + "Program BhNcatUDC2D5JTyeaqrdSukiVFsEHK7e3hVmKMztwefv success", + "Program log: Warp route transfer completed to destination: 1634493807, recipient: 0x1c34\u20262912, remote_amount: 676200000000", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 consumed 225867 of 999700 compute units", + "Program 8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7 success" + ], + "amountLamports": 676200000000, + "recipientAleoAddress": "aleo1rs6fdxg703s3em27uhxsehhfd8znaly22jt6upggrxc77q8d9yfq33pk28", + "senderAddress": "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC", + "uniqueMessageAddress": "7H2KAwXsrVWoAhY9ff1nNanYbp4amnF2mZdwzJDi9AhF", + "lamportDelta": 7023360 +} diff --git a/bridge-sdk/tests/test_sealevel_instruction.py b/bridge-sdk/tests/test_sealevel_instruction.py new file mode 100644 index 00000000..f7542ff9 --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_instruction.py @@ -0,0 +1,41 @@ +import base64 + +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.encoding import aleo_address_to_bytes32 +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ALEO_MAINNET_DOMAIN, TRANSFER + +RECIPIENT32_HEX = "1c3496991e7c611ced5ee5cd0cdee969c53efc8a5497ae050819b1ef00ed2912" + + +def test_instruction_data_matches_mainnet_fixture_byte_for_byte(): + data = sl.build_transfer_remote_instruction_data( + ALEO_MAINNET_DOMAIN, + aleo_address_to_bytes32(TRANSFER["recipientAleoAddress"]), + TRANSFER["amountLamports"], + ) + assert len(data) == sl.INSTRUCTION_DATA_BYTES == 77 + assert base64.b64encode(data).decode() == TRANSFER["instructionDataBase64"] + + +def test_instruction_data_layout_offsets(): + data = sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes.fromhex(RECIPIENT32_HEX), 676_200_000_000) + assert data[0:8] == bytes([1] * 8) == sl.PROGRAM_INSTRUCTION_DISCRIMINATOR + assert data[8] == sl.TRANSFER_REMOTE_VARIANT_TAG == 1 + assert data[9:13] == bytes.fromhex("6f656c61") # 0x616c656f little-endian + assert data[13:45] == bytes.fromhex(RECIPIENT32_HEX) # raw bech32m payload, no reversal + assert data[45:77] == (676_200_000_000).to_bytes(32, "little") + assert data[45:49] == bytes.fromhex("002aa970") + + +def test_instruction_data_rejects_bad_inputs(): + with pytest.raises(BridgeError, match="32 bytes"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(31), 1) + with pytest.raises(BridgeError, match="32-byte unsigned"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(32), 1 << 256) + with pytest.raises(BridgeError, match="32-byte unsigned"): + sl.build_transfer_remote_instruction_data(ALEO_MAINNET_DOMAIN, bytes(32), -1) + with pytest.raises(BridgeError, match="destination domain"): + sl.build_transfer_remote_instruction_data(1 << 32, bytes(32), 1) From 58ac4240842d44d6d0f2d2d4270b550fa864dc3d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:25:04 -0400 Subject: [PATCH 43/94] test(bridge-sdk): leg-1 state files follow the rehearsal convention; README quote_deposit_usdc --- bridge-sdk/README.md | 3 ++ bridge-sdk/tests/live/test_eth_reads.py | 11 ++-- .../tests/live/test_eth_sepolia_leg1.py | 51 ++++++++++++++----- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 149d91c0..7200f193 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -50,6 +50,9 @@ agent/MCP surface arrive in the following plans. result = call.send(on_checkpoint=store.save) # approvals → dispatch; each hash checkpointed before polling result.message_id, result.receipt.status # Hyperlane message id, DELIVERY_PENDING + usdc_quote = bridge.eth.quote_deposit_usdc(aleo_recipient, amount="2", mint_mode="public") + print(usdc_quote.balance_atomic, usdc_quote.approval_required) + deposit = bridge.eth.deposit_usdc(aleo_recipient, amount="2", mint_mode="public").send() deposit.message_hash # Circle attestation lookup key (receipt id), ATTESTATION_PENDING diff --git a/bridge-sdk/tests/live/test_eth_reads.py b/bridge-sdk/tests/live/test_eth_reads.py index 19698106..6afb7726 100644 --- a/bridge-sdk/tests/live/test_eth_reads.py +++ b/bridge-sdk/tests/live/test_eth_reads.py @@ -16,9 +16,12 @@ from aleo_bridge.errors import InsufficientBalanceError from aleo_bridge.eth import Ethereum -pytestmark = pytest.mark.skipif( - os.environ.get("BRIDGE_LIVE_READS") != "1" or not os.environ.get("ETHEREUM_RPC_URL"), - reason="set BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL") +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_READS") != "1" or not os.environ.get("ETHEREUM_RPC_URL"), + reason="set BRIDGE_LIVE_READS=1 and ETHEREUM_RPC_URL"), +] ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" # The xReserve contract custodies deposited USDC, so its balance exceeds the 2 USDC minimum and its @@ -38,6 +41,8 @@ def _run(fn): raise except requests.exceptions.ConnectionError as exc: pytest.skip(f"public RPC unreachable: {exc}") + except requests.exceptions.Timeout: + pytest.skip("public RPC timed out") @pytest.fixture(scope="module") diff --git a/bridge-sdk/tests/live/test_eth_sepolia_leg1.py b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py index fdc3ba8e..a118ab24 100644 --- a/bridge-sdk/tests/live/test_eth_sepolia_leg1.py +++ b/bridge-sdk/tests/live/test_eth_sepolia_leg1.py @@ -4,9 +4,15 @@ EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY. Every checkpoint and the final receipt are written to BRIDGE_LIVE_STATE_DIR (outside the repo) so plan 4's rehearsal runner can `recover`/`wait` and run leg 2 from them. Keys are read from the environment and never written or printed. + +Output layout matches spec §12 / plan 4's rehearsal convention: + - /checkpoints/.json — the FileCheckpointStore (one file per receipt id) + - /leg1.json — latest checkpoint boundary, overwritten atomically + - /receipts.jsonl — one appended line per leg run """ import json import os +import tempfile import time from pathlib import Path @@ -17,9 +23,27 @@ from aleo_bridge.types import Status REQUIRED = ("BRIDGE_LIVE_STATE_DIR", "SEPOLIA_RPC_URL", "EVM_PRIVATE_KEY", "ALEO_E2E_PRIVATE_KEY") -pytestmark = pytest.mark.skipif( - os.environ.get("BRIDGE_LIVE_FUNDS") != "1" or any(not os.environ.get(v) for v in REQUIRED), - reason="set BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY") +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + os.environ.get("BRIDGE_LIVE_FUNDS") != "1" or any(not os.environ.get(v) for v in REQUIRED), + reason="set BRIDGE_LIVE_FUNDS=1, BRIDGE_LIVE_STATE_DIR, SEPOLIA_RPC_URL, EVM_PRIVATE_KEY, ALEO_E2E_PRIVATE_KEY"), +] + + +def _atomic_write(path: Path, text: str) -> None: + """Write *text* to *path* via a same-directory temp file + os.replace (no partial reads).""" + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise def _bridge(): @@ -46,22 +70,23 @@ def test_sepolia_usdc_deposit_public_mint(): quote = eth.quote_deposit_usdc(recipient, amount="2", mint_mode="public") assert quote.plan.route_id == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and quote.plan.recipient == recipient - stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) - checkpoint_path = state_dir / f"leg1-sepolia-usdc-{stamp}.checkpoint.json" - receipt_path = state_dir / f"leg1-sepolia-usdc-{stamp}.receipt.json" + checkpoint_path = state_dir / "leg1.json" def save(checkpoint): - checkpoint_path.write_text(checkpoint.to_json()) # latest boundary wins; the store keeps every id + _atomic_write(checkpoint_path, checkpoint.to_json() + "\n") # latest boundary wins; the store keeps every id result = eth.deposit_usdc(recipient, amount="2", mint_mode="public").send( on_checkpoint=save, timeout_seconds=240.0, poll_seconds=3.0) receipt = result.receipt - receipt_path.write_text(json.dumps({ - "leg": 1, "route_id": result.route_id, "status": receipt.status.value, "receipt_id": receipt.id, - "source_tx_id": receipt.source_tx_id, "message_hash": result.message_hash, "nonce": result.nonce, - "approval_tx_ids": receipt.protocol_state["approvalTxIds"], "recipient": recipient, "sender": eth.conn.address, - "plan": quote.plan.to_dict(), "protocol_state": receipt.protocol_state, - }, indent=2)) + + with (state_dir / "receipts.jsonl").open("a", encoding="utf-8") as fh: + fh.write(json.dumps({ + "leg": 1, "route_id": result.route_id, "source_tx": receipt.source_tx_id, + "message_hash": result.message_hash, "status": receipt.status.value, + "delivery_confirmed": False, "amount_atomic": quote.plan.amount_atomic, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + }) + "\n") + assert checkpoint_path.exists() and bridge.checkpoints.load(receipt.id) is not None assert receipt.status in (Status.ATTESTATION_PENDING, Status.SOURCE_CONFIRMING, Status.SOURCE_APPROVAL_PENDING) if receipt.status == Status.ATTESTATION_PENDING: From 1afbb69af3be08d3e8348bf9832b77f352b0b9d0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:27:56 -0400 Subject: [PATCH 44/94] feat(bridge-sdk): decode sealevel IGP accounts and quote the lamport gas payment --- bridge-sdk/python/aleo_bridge/_sealevel.py | 117 +++++++++++++++++++++ bridge-sdk/tests/test_sealevel_igp.py | 71 +++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 bridge-sdk/tests/test_sealevel_igp.py diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 5ba279b3..795baff5 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -9,6 +9,9 @@ """ from __future__ import annotations +from dataclasses import dataclass + +from ._base58 import b58encode from .errors import ConfigurationError, InvalidAmountError, InvalidRecipientError # SEALEVEL_NOTES §1: every Sealevel Hyperlane instruction is prefixed with this @@ -41,3 +44,117 @@ def build_transfer_remote_instruction_data(destination_domain: int, recipient32: ) assert len(data) == INSTRUCTION_DATA_BYTES return data + + +# SEALEVEL_NOTES §4: AccountData> layout and compute_gas_fee constants. +IGP_DISCRIMINATOR = b"IGP_____" +TOKEN_EXCHANGE_RATE_SCALE = 10 ** 19 # exchange rate 1.0 is stored as 10^19 +SOL_DECIMALS = 9 +GAS_ORACLE_ENTRY_BYTES = 38 # [4B domain][1B tag][16B exchange rate][16B gas price][1B decimals] +REMOTE_GAS_DATA_TAG = 0 # the only GasOracle variant defined today + + +@dataclass(frozen=True) +class GasOracle: + token_exchange_rate: int + gas_price: int + token_decimals: int + + +@dataclass(frozen=True) +class IgpAccount: + bump: int + salt: bytes + owner: str | None + beneficiary: str + gas_oracles: dict[int, GasOracle] + unsupported_oracles: dict[int, int] # domain -> variant tag, for entries that are not RemoteGasData + + +class _Cursor: + """Little-endian Borsh reader over immutable bytes.""" + + def __init__(self, data: bytes) -> None: + self._data = bytes(data) + self._offset = 0 + + def take(self, size: int) -> bytes: + end = self._offset + size + if end > len(self._data): + raise ConfigurationError("malformed Sealevel IGP account data: declared layout exceeds the supplied bytes") + chunk = self._data[self._offset:end] + self._offset = end + return chunk + + def u8(self) -> int: + return self.take(1)[0] + + def u32(self) -> int: + return int.from_bytes(self.take(4), "little") + + def u128(self) -> int: + return int.from_bytes(self.take(16), "little") + + def pubkey(self) -> str: + return b58encode(self.take(32)) + + +def decode_igp_account(data: bytes) -> IgpAccount: + """Decode the terminal ``Igp`` account (the ``inner`` of an OverheadIgp), SEALEVEL_NOTES §4.""" + cursor = _Cursor(data) + if cursor.u8() != 1: + raise ConfigurationError("Sealevel IGP account is not initialized") + discriminator = cursor.take(8) + if discriminator != IGP_DISCRIMINATOR: + raise ConfigurationError( + f"Sealevel IGP account has an unexpected discriminator {discriminator!r}; expected {IGP_DISCRIMINATOR!r}" + ) + bump = cursor.u8() + salt = cursor.take(32) + owner_tag = cursor.u8() + if owner_tag not in (0, 1): + raise ConfigurationError(f"malformed Sealevel IGP account data: unsupported owner option tag {owner_tag}") + owner = cursor.pubkey() if owner_tag == 1 else None + beneficiary = cursor.pubkey() + count = cursor.u32() + oracles: dict[int, GasOracle] = {} + unsupported: dict[int, int] = {} + for _ in range(count): + domain = cursor.u32() + tag = cursor.u8() + exchange_rate = cursor.u128() + gas_price = cursor.u128() + decimals = cursor.u8() + if tag == REMOTE_GAS_DATA_TAG: + oracles[domain] = GasOracle(exchange_rate, gas_price, decimals) + else: + unsupported[domain] = tag + return IgpAccount(bump, salt, owner, beneficiary, oracles, unsupported) + + +def igp_lamports(oracle: GasOracle, gas_amount: int) -> int: + """``compute_gas_fee`` + ``convert_decimals`` (SEALEVEL_NOTES §4), exact integer arithmetic.""" + destination_cost = gas_amount * oracle.gas_price + origin_cost = destination_cost * oracle.token_exchange_rate // TOKEN_EXCHANGE_RATE_SCALE + if oracle.token_decimals <= SOL_DECIMALS: + return origin_cost * 10 ** (SOL_DECIMALS - oracle.token_decimals) + return origin_cost // 10 ** (oracle.token_decimals - SOL_DECIMALS) + + +def quote_igp_lamports(igp_account_data: bytes, destination_domain: int, gas_amount: int) -> int: + """Lamports the IGP charges to deliver ``gas_amount`` destination gas to ``destination_domain``. + + ``gas_amount`` is the warp token's ``destination_gas`` for the domain (route metadata + ``destinationGasAmount``, 464000 for Aleo), not derived from the message. + """ + account = decode_igp_account(igp_account_data) + if destination_domain in account.unsupported_oracles: + tag = account.unsupported_oracles[destination_domain] + raise ConfigurationError( + f"Sealevel IGP account has an unexpected GasOracle variant tag {tag} for domain {destination_domain}; " + "only variant 0 (RemoteGasData) is decoded" + ) + oracle = account.gas_oracles.get(destination_domain) + if oracle is None: + raise ConfigurationError(f"Sealevel IGP account has no gas-oracle entry for destination domain {destination_domain}") + return igp_lamports(oracle, gas_amount) diff --git a/bridge-sdk/tests/test_sealevel_igp.py b/bridge-sdk/tests/test_sealevel_igp.py new file mode 100644 index 00000000..ce08dfc6 --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_igp.py @@ -0,0 +1,71 @@ +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ( + ALEO_MAINNET_DOMAIN, + DESTINATION_GAS_AMOUNT, + EXPECTED_IGP_PAYMENT_LAMPORTS, + IGP, + igp_account_data, +) + +HEADER_NO_OWNER = 1 + 8 + 1 + 32 + 1 + 32 + 4 # initialized, disc, bump, salt, owner=None, beneficiary, count + + +def synthetic_igp(domain: int, *, tag: int = 0, rate: int = 1, price: int = 1, decimals: int = 9) -> bytes: + data = bytearray(HEADER_NO_OWNER + sl.GAS_ORACLE_ENTRY_BYTES) + data[0] = 1 + data[1:9] = sl.IGP_DISCRIMINATOR + data[HEADER_NO_OWNER - 4:HEADER_NO_OWNER] = (1).to_bytes(4, "little") + entry = HEADER_NO_OWNER + data[entry:entry + 4] = domain.to_bytes(4, "little") + data[entry + 4] = tag + data[entry + 5:entry + 21] = rate.to_bytes(16, "little") + data[entry + 21:entry + 37] = price.to_bytes(16, "little") + data[entry + 37] = decimals + return bytes(data) + + +def test_decode_recorded_inner_igp_account(): + account = sl.decode_igp_account(igp_account_data()) + assert account.bump == 255 + assert account.owner is not None and len(account.owner) in (43, 44) + assert len(account.beneficiary) in (43, 44) + assert account.gas_oracles[ALEO_MAINNET_DOMAIN] == sl.GasOracle(751_705_303_136, 83_169, 6) + assert len(account.gas_oracles) + len(account.unsupported_oracles) == 42 + + +def test_quote_reproduces_sealevel_notes_vector(): + assert sl.quote_igp_lamports(igp_account_data(), ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT) == EXPECTED_IGP_PAYMENT_LAMPORTS + assert sl.igp_lamports(sl.GasOracle(751_705_303_136, 83_169, 6), 464_000) == 2_900_000 + + +def test_igp_lamports_divides_when_token_decimals_exceed_nine(): + # dest_cost = 10^6 * 1 ; origin_cost = 10^6 * 10^19 / 10^19 = 10^6 ; decimals 12 → // 10^3 + assert sl.igp_lamports(sl.GasOracle(10 ** 19, 1, 12), 10 ** 6) == 1_000 + assert sl.igp_lamports(sl.GasOracle(10 ** 19, 1, 9), 10 ** 6) == 1_000_000 + + +def test_quote_rejects_missing_domain_and_unknown_variant_tag(): + with pytest.raises(BridgeError, match="no gas-oracle entry for destination domain 999999999"): + sl.quote_igp_lamports(igp_account_data(), 999_999_999, 1) + with pytest.raises(BridgeError, match="unexpected GasOracle variant tag 7"): + sl.quote_igp_lamports(synthetic_igp(42, tag=7), 42, 1) + assert sl.quote_igp_lamports(synthetic_igp(42, rate=10 ** 19, price=5, decimals=9), 42, 3) == 15 + + +def test_decode_rejects_malformed_layouts(): + with pytest.raises(BridgeError, match="not initialized"): + sl.decode_igp_account(bytes(12)) + with pytest.raises(BridgeError, match="declared layout exceeds the supplied bytes"): + sl.decode_igp_account(b"\x01" + sl.IGP_DISCRIMINATOR + bytes(3)) + with pytest.raises(BridgeError, match="discriminator"): + sl.decode_igp_account(b"\x01" + b"WRONGDIS" + bytes(80)) + bad_owner = bytearray(80) + bad_owner[0] = 1 + bad_owner[1:9] = sl.IGP_DISCRIMINATOR + bad_owner[42] = 2 + with pytest.raises(BridgeError, match="owner option tag 2"): + sl.decode_igp_account(bytes(bad_owner)) + assert IGP["address"] == "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M" From 4103b936c77a157994086c540ac7e3c62c9aa71e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:30:59 -0400 Subject: [PATCH 45/94] feat(bridge-sdk): sealevel route metadata, pure PDA derivation, and the TransferRemote account table --- bridge-sdk/python/aleo_bridge/_sealevel.py | 214 ++++++++++++++++++++- bridge-sdk/tests/test_sealevel_accounts.py | 105 ++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/tests/test_sealevel_accounts.py diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 795baff5..3b880230 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -9,10 +9,14 @@ """ from __future__ import annotations +import hashlib +import re from dataclasses import dataclass +from typing import Mapping, Sequence -from ._base58 import b58encode -from .errors import ConfigurationError, InvalidAmountError, InvalidRecipientError +from ._base58 import b58decode, b58encode +from .errors import BridgeError, ConfigurationError, InvalidAmountError, InvalidRecipientError, RouteUnavailableError +from .registry import Route # SEALEVEL_NOTES §1: every Sealevel Hyperlane instruction is prefixed with this # fixed 8-byte discriminator; TransferRemote is Borsh enum variant 1. @@ -158,3 +162,209 @@ def quote_igp_lamports(igp_account_data: bytes, destination_domain: int, gas_amo if oracle is None: raise ConfigurationError(f"Sealevel IGP account has no gas-oracle entry for destination domain {destination_domain}") return igp_lamports(oracle, gas_amount) + + +# --- Route metadata ---------------------------------------------------------------------------- + +SOLANA_ROUTE_ID = "hyperlane:solana/sol->aleo/sol" +SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111" +SOLANA_PUBKEY_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") + +# SEALEVEL_NOTES §3: seeds are separate byte strings (separators are their own seed). +DISPATCHED_MESSAGE_SEED_PREFIX = (b"hyperlane", b"-", b"dispatched_message", b"-") +GAS_PAYMENT_SEED_PREFIX = (b"hyperlane_igp", b"-", b"gas_payment", b"-") +PDA_MARKER = b"ProgramDerivedAddress" +MAX_SEEDS = 16 +MAX_SEED_LENGTH = 32 + +# veil protocols/hyperlane/solana.ts: rent for the two accounts a transfer creates (gas-payment PDA, +# dispatched-message PDA) plus the sender's own rent floor; compute-unit limit set on every transfer. +GAS_PAYMENT_ACCOUNT_DATA_LENGTH = 141 +DISPATCHED_MESSAGE_ACCOUNT_DATA_LENGTH = 194 +COMPUTE_UNIT_LIMIT = 400_000 + + +@dataclass(frozen=True) +class SolanaRouteMetadata: + warp_program_address: str + token_pda: str + native_collateral_pda: str + dispatch_authority_pda: str + mailbox_program_address: str + mailbox_outbox_pda: str + igp_program_address: str + igp_program_data_pda: str + igp_account: str + igp_overhead_account: str | None + spl_noop_program_address: str + destination_domain: int + destination_gas_amount: int + registry_commit: str + solana_reviewed_at: str + solana_config_source: str + + +_PUBKEY_FIELDS = ( + ("warpProgramAddress", "warp_program_address"), + ("tokenPda", "token_pda"), + ("nativeCollateralPda", "native_collateral_pda"), + ("dispatchAuthorityPda", "dispatch_authority_pda"), + ("mailboxProgramAddress", "mailbox_program_address"), + ("mailboxOutboxPda", "mailbox_outbox_pda"), + ("igpProgramAddress", "igp_program_address"), + ("igpProgramDataPda", "igp_program_data_pda"), + ("igpAccount", "igp_account"), + ("splNoopProgramAddress", "spl_noop_program_address"), +) + + +def solana_route_metadata(route: Route) -> SolanaRouteMetadata: + """Validate and return the reviewed Solana deployment metadata (veil ``solanaMetadata.ts``). + + Every address participates in instruction account ordering, so the whole route is + refused when one field is missing or malformed rather than letting a bad key through. + """ + if route.protocol != "hyperlane": + raise RouteUnavailableError(f"Solana Hyperlane actions require a Hyperlane route, got {route.protocol}: {route.id}") + if route.availability != "active": + raise RouteUnavailableError(f"Hyperlane route is not executable: {route.id} ({route.availability})") + metadata: Mapping[str, object] = route.metadata or {} + + def pubkey(key: str) -> str: + value = metadata.get(key) + if not isinstance(value, str) or not SOLANA_PUBKEY_RE.match(value): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid {key}: {route.id}") + return value + + fields = {attr: pubkey(key) for key, attr in _PUBKEY_FIELDS} + overhead = metadata.get("igpOverheadAccount") + fields["igp_overhead_account"] = None if overhead is None else pubkey("igpOverheadAccount") + + domain = metadata.get("destinationDomain") + if isinstance(domain, bool) or not isinstance(domain, int) or not 0 <= domain <= 0xFFFF_FFFF: + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid destinationDomain: {route.id}") + gas = metadata.get("destinationGasAmount") + if isinstance(gas, bool) or not (isinstance(gas, int) or (isinstance(gas, str) and gas.isdigit())): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid destinationGasAmount: {route.id}") + commit = metadata.get("registryCommit") + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", commit): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid registryCommit: {route.id}") + reviewed = metadata.get("solanaReviewedAt") + if not isinstance(reviewed, str) or not re.match(r"^\d{4}-\d{2}-\d{2}", reviewed): + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid solanaReviewedAt: {route.id}") + source = metadata.get("solanaConfigSource") + if not isinstance(source, str) or not source: + raise RouteUnavailableError(f"Solana Hyperlane route has an invalid solanaConfigSource: {route.id}") + return SolanaRouteMetadata( + destination_domain=domain, + destination_gas_amount=int(gas), + registry_commit=commit, + solana_reviewed_at=reviewed, + solana_config_source=source, + **fields, + ) + + +# --- PDAs ------------------------------------------------------------------------------------- + +_ED25519_P = 2 ** 255 - 19 +_ED25519_D = (-121665 * pow(121666, -1, _ED25519_P)) % _ED25519_P + + +def _is_on_curve(point: bytes) -> bool: + """Whether a compressed Edwards Y coordinate decompresses (curve25519-dalek ``decompress``): + x² = (y² − 1) / (d·y² + 1) must have a square root (or be zero).""" + y = (int.from_bytes(point, "little") & ((1 << 255) - 1)) % _ED25519_P + y2 = y * y % _ED25519_P + u = (y2 - 1) % _ED25519_P + v = (_ED25519_D * y2 + 1) % _ED25519_P + if v == 0: + return u == 0 + x2 = u * pow(v, -1, _ED25519_P) % _ED25519_P + return x2 == 0 or pow(x2, (_ED25519_P - 1) // 2, _ED25519_P) == 1 + + +def create_program_address(seeds: Sequence[bytes], program_id: str) -> str | None: + """``Pubkey::create_program_address``: sha256(seeds ‖ program_id ‖ marker); None when on-curve.""" + if len(seeds) > MAX_SEEDS: + raise BridgeError(f"program address derivation accepts at most {MAX_SEEDS} seeds") + digest = hashlib.sha256() + for seed in seeds: + if len(seed) > MAX_SEED_LENGTH: + raise BridgeError(f"each program address seed must be at most {MAX_SEED_LENGTH} bytes") + digest.update(bytes(seed)) + digest.update(b58decode(program_id)) + digest.update(PDA_MARKER) + candidate = digest.digest() + return None if _is_on_curve(candidate) else b58encode(candidate) + + +def find_program_address(seeds: Sequence[bytes], program_id: str) -> tuple[str, int]: + """``Pubkey::find_program_address``: try bump seeds 255 → 0, return the first off-curve address.""" + for bump in range(255, -1, -1): + address = create_program_address([*seeds, bytes([bump])], program_id) + if address is not None: + return address, bump + raise BridgeError("unable to find a viable program address bump seed") + + +def derive_dispatched_message_pda(mailbox_program_address: str, unique_message_address: str) -> str: + """Mailbox ``["hyperlane","-","dispatched_message","-", unique_message_pubkey]`` (SEALEVEL_NOTES §3).""" + return find_program_address([*DISPATCHED_MESSAGE_SEED_PREFIX, b58decode(unique_message_address)], mailbox_program_address)[0] + + +def derive_gas_payment_pda(igp_program_address: str, unique_message_address: str) -> str: + """IGP ``["hyperlane_igp","-","gas_payment","-", unique_message_pubkey]`` — same unique key as the message PDA.""" + return find_program_address([*GAS_PAYMENT_SEED_PREFIX, b58decode(unique_message_address)], igp_program_address)[0] + + +# --- Account table ---------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SolanaAccountMeta: + address: str + signer: bool + writable: bool + + def to_dict(self) -> dict[str, str | bool]: + return {"address": self.address, "signer": self.signer, "writable": self.writable} + + +def account_metas(metadata: SolanaRouteMetadata, sender: str, unique_message: str) -> list[SolanaAccountMeta]: + """The native-collateral ``TransferRemote`` account list, SEALEVEL_NOTES §2 rows 0–15. + + Row 12 (``igpOverheadAccount``) is present only when the route wraps its IGP in an + OverheadIgp; the list then has 16 entries, otherwise 15. The sender compiles writable + (the native-collateral ``transfer_in`` CPI debits it) and the unique-message account is + a read-only signer. + """ + def ro(address: str) -> SolanaAccountMeta: + return SolanaAccountMeta(address, False, False) + + def rw(address: str) -> SolanaAccountMeta: + return SolanaAccountMeta(address, False, True) + + dispatched_message = derive_dispatched_message_pda(metadata.mailbox_program_address, unique_message) + gas_payment = derive_gas_payment_pda(metadata.igp_program_address, unique_message) + metas = [ + ro(SYSTEM_PROGRAM_ADDRESS), # 0 + ro(metadata.spl_noop_program_address), # 1 + ro(metadata.token_pda), # 2 + ro(metadata.mailbox_program_address), # 3 + rw(metadata.mailbox_outbox_pda), # 4 + ro(metadata.dispatch_authority_pda), # 5 + SolanaAccountMeta(sender, True, True), # 6 sender / fee payer + SolanaAccountMeta(unique_message, True, False), # 7 unique message (readonly signer) + rw(dispatched_message), # 8 + ro(metadata.igp_program_address), # 9 + rw(metadata.igp_program_data_pda), # 10 + rw(gas_payment), # 11 + ] + if metadata.igp_overhead_account is not None: + metas.append(ro(metadata.igp_overhead_account)) # 12 (optional) + metas.extend([ + rw(metadata.igp_account), # 13 + ro(SYSTEM_PROGRAM_ADDRESS), # 14 + rw(metadata.native_collateral_pda), # 15 + ]) + return metas diff --git a/bridge-sdk/tests/test_sealevel_accounts.py b/bridge-sdk/tests/test_sealevel_accounts.py new file mode 100644 index 00000000..834e4e3e --- /dev/null +++ b/bridge-sdk/tests/test_sealevel_accounts.py @@ -0,0 +1,105 @@ +import dataclasses + +import pytest + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import RouteUnavailableError +from aleo_bridge.registry import DEFAULT_REGISTRY, Route +from tests.fakes.sealevel_fixtures import TRANSFER, WARP_PROGRAM_ADDRESS, metadata_from_fixture + +ACCOUNTS = TRANSFER["accounts"] + + +def route_with(metadata: dict) -> Route: + base = DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID) + return dataclasses.replace(base, availability="active", metadata=metadata) + + +def test_pdas_match_the_recorded_transaction(): + unique = TRANSFER["uniqueMessageAddress"] + assert sl.derive_dispatched_message_pda(ACCOUNTS[3]["address"], unique) == ACCOUNTS[8]["address"] + assert sl.derive_gas_payment_pda(ACCOUNTS[9]["address"], unique) == ACCOUNTS[11]["address"] + dispatched, bump_dispatched = sl.find_program_address( + [*sl.DISPATCHED_MESSAGE_SEED_PREFIX, sl.b58decode(unique)], ACCOUNTS[3]["address"]) + gas_payment, bump_gas = sl.find_program_address( + [*sl.GAS_PAYMENT_SEED_PREFIX, sl.b58decode(unique)], ACCOUNTS[9]["address"]) + assert (dispatched, bump_dispatched) == (ACCOUNTS[8]["address"], 253) # SEALEVEL_NOTES §2 bump seeds + assert (gas_payment, bump_gas) == (ACCOUNTS[11]["address"], 255) + + +def test_route_static_pdas_recompute_from_the_warp_program(): + # The registry's tokenPda / dispatchAuthorityPda / nativeCollateralPda / outbox / IGP program-data are + # PDAs of the warp, mailbox and IGP programs (SEALEVEL_NOTES §2-3); bumps 255, 254, 255, 255, 254. + assert sl.find_program_address([b"hyperlane_message_recipient", b"-", b"handle", b"-", b"account_metas"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[2]["address"], 255) + assert sl.find_program_address([b"hyperlane_dispatcher", b"-", b"dispatch_authority"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[5]["address"], 254) + assert sl.find_program_address([b"hyperlane_token", b"-", b"native_collateral"], WARP_PROGRAM_ADDRESS) == (ACCOUNTS[15]["address"], 255) + assert sl.find_program_address([b"hyperlane", b"-", b"outbox"], ACCOUNTS[3]["address"]) == (ACCOUNTS[4]["address"], 255) + assert sl.find_program_address([b"hyperlane_igp", b"-", b"program_data"], ACCOUNTS[9]["address"]) == (ACCOUNTS[10]["address"], 254) + + +def test_pda_derivation_agrees_with_solders_on_random_keys(): + solders_keypair = pytest.importorskip("solders.keypair") + from solders.pubkey import Pubkey + + mailbox = Pubkey.from_string(ACCOUNTS[3]["address"]) + for _ in range(16): + unique = solders_keypair.Keypair().pubkey() + seeds = [*sl.DISPATCHED_MESSAGE_SEED_PREFIX, bytes(unique)] + expected, expected_bump = Pubkey.find_program_address(seeds, mailbox) + assert sl.find_program_address(seeds, str(mailbox)) == (str(expected), expected_bump) + + +def test_find_program_address_rejects_oversized_seeds(): + with pytest.raises(sl.BridgeError, match="32 bytes"): + sl.find_program_address([bytes(33)], WARP_PROGRAM_ADDRESS) + with pytest.raises(sl.BridgeError, match="16 seeds"): + sl.find_program_address([b"x"] * 17, WARP_PROGRAM_ADDRESS) + + +def test_account_metas_reproduce_the_recorded_16_accounts(): + metadata = sl.solana_route_metadata(route_with(metadata_from_fixture())) + metas = sl.account_metas(metadata, TRANSFER["senderAddress"], TRANSFER["uniqueMessageAddress"]) + assert len(metas) == 16 + assert [m.to_dict() for m in metas] == ACCOUNTS + + +def test_account_metas_omit_the_optional_overhead_slot(): + metadata = sl.solana_route_metadata(route_with(metadata_from_fixture(overhead=False))) + metas = sl.account_metas(metadata, TRANSFER["senderAddress"], TRANSFER["uniqueMessageAddress"]) + overhead = ACCOUNTS[12]["address"] + assert len(metas) == 15 + assert overhead not in [m.address for m in metas] + assert [m.to_dict() for m in metas] == [a for a in ACCOUNTS if a["address"] != overhead] + + +def test_default_registry_route_carries_the_recorded_deployment(): + live = sl.solana_route_metadata(DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID)) + recorded = sl.solana_route_metadata(route_with(metadata_from_fixture())) + for field in ("warp_program_address", "token_pda", "native_collateral_pda", "dispatch_authority_pda", + "mailbox_program_address", "mailbox_outbox_pda", "igp_program_address", "igp_program_data_pda", + "igp_account", "igp_overhead_account", "spl_noop_program_address", "destination_domain", + "destination_gas_amount", "registry_commit"): + assert getattr(live, field) == getattr(recorded, field), field + assert live.destination_gas_amount == 464_000 + assert live.igp_overhead_account == "AkeHBbE5JkwVppujCQQ6WuxsVsJtruBAjUo6fDCFp6fF" + + +def test_route_metadata_validation(): + inactive = dataclasses.replace(DEFAULT_REGISTRY.route(sl.SOLANA_ROUTE_ID), availability="metadata-required") + with pytest.raises(RouteUnavailableError, match="not executable"): + sl.solana_route_metadata(inactive) + with pytest.raises(RouteUnavailableError, match="invalid igpAccount"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "igpAccount": "not-a-solana-address"})) + with pytest.raises(RouteUnavailableError, match="invalid destinationDomain"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "destinationDomain": "1634493807"})) + with pytest.raises(RouteUnavailableError, match="invalid destinationGasAmount"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "destinationGasAmount": "lots"})) + with pytest.raises(RouteUnavailableError, match="invalid registryCommit"): + sl.solana_route_metadata(route_with({**metadata_from_fixture(), "registryCommit": "418056e2"})) + missing = metadata_from_fixture() + del missing["mailboxOutboxPda"] + with pytest.raises(RouteUnavailableError, match="invalid mailboxOutboxPda"): + sl.solana_route_metadata(route_with(missing)) + xreserve = DEFAULT_REGISTRY.route("xreserve:ethereum/usdc->aleo/usdcx") + with pytest.raises(RouteUnavailableError, match="Hyperlane"): + sl.solana_route_metadata(xreserve) From 6b6553a70217722cfa5c4313aba5cbfbfe3d5a0c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 17:38:40 -0400 Subject: [PATCH 46/94] feat(bridge-sdk): Solana sync JSON-RPC transport and connection (rpc_url/client, key parsing, Signer protocol) --- bridge-sdk/python/aleo_bridge/__init__.py | 2 + bridge-sdk/python/aleo_bridge/sol.py | 436 ++++++++++++++++++++++ bridge-sdk/tests/test_client.py | 14 +- bridge-sdk/tests/test_sol_connection.py | 195 ++++++++++ bridge-sdk/tests/test_sol_rpc.py | 186 +++++++++ 5 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/sol.py create mode 100644 bridge-sdk/tests/test_sol_connection.py create mode 100644 bridge-sdk/tests/test_sol_rpc.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 207be0c7..5fcc3472 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -30,6 +30,7 @@ from .hyperlane import HyperlaneModule # noqa: E402 from .privacy import PrivacyModule # noqa: E402 from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 +from .sol import Solana # noqa: E402 from .xreserve import XReserveModule # noqa: E402 __all__ = [ @@ -47,4 +48,5 @@ "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", + "Solana", ] diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py new file mode 100644 index 00000000..15b088d8 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -0,0 +1,436 @@ +"""Solana transport, connection and the ``bridge.sol`` module (SOL → Aleo over Hyperlane). + +solders is imported lazily through :func:`_libs`; an install without the ``solana`` extra +raises :class:`MissingExtraError` at the point of use, never at import. Layouts live in +:mod:`aleo_bridge._sealevel` (pure); this module adds a synchronous JSON-RPC transport +(solana-py ≥ 0.36 is async-only), signing, broadcast and status polling. +""" +from __future__ import annotations + +import asyncio +import base64 +import inspect +import json +import os +import threading +import time +from dataclasses import dataclass, replace +from typing import Any, Callable, Mapping, Protocol, Sequence, runtime_checkable + +import requests + +from . import _sealevel as sl +from .encoding import aleo_address_to_bytes32 +from .errors import ( + BridgeError, + CheckpointInvalidError, + ConfigurationError, + InsufficientBalanceError, + InvalidAmountError, + MissingExtraError, + RegistryVersionMismatchError, + UnsupportedRouteError, +) +from .registry import Route +from .types import DispatchReceipt, Fee, Plan, Receipt, SolanaHyperlaneQuote, Status, Step +from .units import format_decimal_amount, resolve_amount + +DEFAULT_SOLANA_RPC_URL = "https://api.mainnet-beta.solana.com" +CONFIRMED = "confirmed" +COMMITMENTS = ("processed", "confirmed", "finalized") +SOLANA_CHAIN_ID = "solana" +SOLANA_SOL_ASSET_ID = "solana/sol" +ALEO_SOL_ASSET_ID = "aleo/sol" + + +@dataclass(frozen=True) +class _SolanaLibs: + Keypair: Any + Pubkey: Any + Signature: Any + Hash: Any + Instruction: Any + AccountMeta: Any + MessageV0: Any + to_bytes_versioned: Any + VersionedTransaction: Any + set_compute_unit_limit: Any + + +_LIBS: _SolanaLibs | None = None + + +def _libs() -> _SolanaLibs: + """Import solders once; translate a missing extra into MissingExtraError.""" + global _LIBS + if _LIBS is None: + try: + from solders.compute_budget import set_compute_unit_limit + from solders.hash import Hash + from solders.instruction import AccountMeta, Instruction + from solders.keypair import Keypair + from solders.message import MessageV0, to_bytes_versioned + from solders.pubkey import Pubkey + from solders.signature import Signature + from solders.transaction import VersionedTransaction + except ImportError as exc: + raise MissingExtraError("solana", "Solana connections and SOL transfers") from exc + _LIBS = _SolanaLibs(Keypair, Pubkey, Signature, Hash, Instruction, AccountMeta, MessageV0, + to_bytes_versioned, VersionedTransaction, set_compute_unit_limit) + return _LIBS + + +# --- synchronous JSON-RPC transport (veil src/solana/rpc.ts) ------------------------------------ + +@dataclass(frozen=True) +class SendOptions: + """Broadcast options; attribute-compatible with solana-py's ``TxOpts``.""" + skip_preflight: bool = False + preflight_commitment: str = CONFIRMED + skip_confirmation: bool = True + + +@dataclass(frozen=True) +class RpcResult: + value: Any + + +@dataclass(frozen=True) +class LatestBlockhash: + blockhash: Any # solders Hash + last_valid_block_height: int + + +@dataclass(frozen=True) +class AccountInfo: + data: bytes + lamports: int + owner: str + + +@dataclass(frozen=True) +class SignatureStatus: + err: Any + confirmation_status: str | None + + +@dataclass(frozen=True) +class TransactionMeta: + log_messages: list[str] | None + + +@dataclass(frozen=True) +class TransactionWithMeta: + meta: TransactionMeta | None + + +@dataclass(frozen=True) +class ConfirmedTransaction: + transaction: TransactionWithMeta + slot: int + + +class SolanaRpcClient: + """Minimal synchronous Solana JSON-RPC client exposing the solana-py method surface SolModule uses. + + Every method issues one POST, validates the envelope (HTTP status, JSON, JSON-RPC ``error``, + ``result`` presence) and the result shape, and returns an object with ``.value`` shaped like + solana-py's response types. No method signs or retries. + """ + + def __init__(self, url: str, *, commitment: str = CONFIRMED, session: Any = None, timeout: float = 30.0) -> None: + if commitment not in COMMITMENTS: + raise ConfigurationError(f"Solana commitment must be one of {COMMITMENTS}, got {commitment!r}") + self.url = url + self.commitment = commitment + self.timeout = timeout + self._session = session or requests.Session() + + def _commitment(self, commitment: str | None) -> dict[str, str]: + return {"commitment": str(commitment or self.commitment)} + + def _call(self, method: str, params: list[Any]) -> Any: + try: + response = self._session.post(self.url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=self.timeout, headers={"content-type": "application/json", "cache-control": "no-cache"}) + except requests.RequestException as exc: + raise BridgeError(f"Solana RPC {method} request failed: {exc}") from exc + if not 200 <= response.status_code < 300: + raise BridgeError(f"Solana RPC {method} request failed with HTTP status {response.status_code}") + try: + body = response.json() + except ValueError as exc: + raise BridgeError(f"Solana RPC {method} returned invalid JSON") from exc + if not isinstance(body, dict): + raise BridgeError(f"Solana RPC {method} returned an invalid JSON-RPC response") + error = body.get("error") + if error: + details = f"; {json.dumps(error['data'])}" if isinstance(error, dict) and "data" in error else "" + message = error.get("message", "unknown error") if isinstance(error, dict) else str(error) + raise BridgeError(f"Solana RPC {method} returned a JSON-RPC error: {message}{details}") + if "result" not in body: + raise BridgeError(f"Solana RPC {method} returned an invalid result envelope") + return body["result"] + + @staticmethod + def _integer(method: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise BridgeError(f"Solana RPC {method} returned an invalid result") + return value + + @staticmethod + def _contextual(method: str, result: Any) -> Any: + if not isinstance(result, dict) or "value" not in result: + raise BridgeError(f"Solana RPC {method} returned an invalid contextual result") + return result["value"] + + def get_latest_blockhash(self, commitment: str | None = None) -> RpcResult: + value = self._contextual("getLatestBlockhash", self._call("getLatestBlockhash", [self._commitment(commitment)])) + if not isinstance(value, dict) or not isinstance(value.get("blockhash"), str) or not value["blockhash"]: + raise BridgeError("Solana RPC getLatestBlockhash returned an invalid result") + return RpcResult(LatestBlockhash(_libs().Hash.from_string(value["blockhash"]), + self._integer("getLatestBlockhash", value.get("lastValidBlockHeight")))) + + def get_block_height(self, commitment: str | None = None) -> RpcResult: + return RpcResult(self._integer("getBlockHeight", self._call("getBlockHeight", [self._commitment(commitment)]))) + + def is_blockhash_valid(self, blockhash: Any, commitment: str | None = None) -> RpcResult: + value = self._contextual("isBlockhashValid", self._call("isBlockhashValid", [str(blockhash), self._commitment(commitment)])) + if not isinstance(value, bool): + raise BridgeError("Solana RPC isBlockhashValid returned an invalid result") + return RpcResult(value) + + def get_balance(self, pubkey: Any, commitment: str | None = None) -> RpcResult: + value = self._contextual("getBalance", self._call("getBalance", [str(pubkey), self._commitment(commitment)])) + return RpcResult(self._integer("getBalance", value)) + + def get_account_info(self, pubkey: Any, commitment: str | None = None, encoding: str = "base64") -> RpcResult: + if encoding != "base64": + raise BridgeError("SolanaRpcClient.get_account_info supports base64 encoding only") + value = self._contextual("getAccountInfo", self._call("getAccountInfo", [str(pubkey), {"encoding": "base64", **self._commitment(commitment)}])) + if value is None: + return RpcResult(None) + data = value.get("data") if isinstance(value, dict) else None + if not isinstance(data, list) or len(data) != 2 or not isinstance(data[0], str) or data[1] != "base64": + raise BridgeError("Solana RPC getAccountInfo returned invalid base64 account data") + try: + raw = base64.b64decode(data[0], validate=True) + except ValueError as exc: + raise BridgeError("Solana RPC getAccountInfo returned invalid base64 account data") from exc + return RpcResult(AccountInfo(raw, int(value.get("lamports", 0)), str(value.get("owner", "")))) + + def get_fee_for_message(self, message: Any, commitment: str | None = None) -> RpcResult: + raw = bytes(message) if isinstance(message, (bytes, bytearray)) else _libs().to_bytes_versioned(message) + value = self._contextual("getFeeForMessage", self._call("getFeeForMessage", [base64.b64encode(raw).decode(), self._commitment(commitment)])) + return RpcResult(None if value is None else self._integer("getFeeForMessage", value)) + + def get_minimum_balance_for_rent_exemption(self, usize: int, commitment: str | None = None) -> RpcResult: + if isinstance(usize, bool) or not isinstance(usize, int) or usize < 0: + raise BridgeError("Solana rent data length must be a non-negative integer") + return RpcResult(self._integer("getMinimumBalanceForRentExemption", + self._call("getMinimumBalanceForRentExemption", [usize, self._commitment(commitment)]))) + + def send_raw_transaction(self, txn: bytes, opts: Any = None) -> RpcResult: + opts = opts or SendOptions() + config = {"encoding": "base64", "skipPreflight": bool(opts.skip_preflight), "preflightCommitment": str(opts.preflight_commitment)} + result = self._call("sendTransaction", [base64.b64encode(bytes(txn)).decode(), config]) + if not isinstance(result, str) or not result: + raise BridgeError("Solana RPC sendTransaction returned an invalid signature") + return RpcResult(_libs().Signature.from_string(result)) + + def get_signature_statuses(self, signatures: Sequence[Any], search_transaction_history: bool = False) -> RpcResult: + value = self._contextual("getSignatureStatuses", self._call( + "getSignatureStatuses", [[str(s) for s in signatures], {"searchTransactionHistory": bool(search_transaction_history)}])) + if not isinstance(value, list) or len(value) != len(signatures): + raise BridgeError("Solana RPC getSignatureStatuses returned an invalid result") + statuses: list[SignatureStatus | None] = [] + for status in value: + if status is None: + statuses.append(None) + continue + if not isinstance(status, dict) or "err" not in status: + raise BridgeError("Solana RPC getSignatureStatuses returned an invalid status") + confirmation = status.get("confirmationStatus") + if confirmation is not None and confirmation not in COMMITMENTS: + raise BridgeError(f"Solana RPC getSignatureStatuses returned unsupported confirmation status: {confirmation}") + statuses.append(SignatureStatus(status["err"], confirmation)) + return RpcResult(statuses) + + def get_transaction(self, tx_sig: Any, encoding: str = "json", commitment: str | None = None, + max_supported_transaction_version: int | None = None) -> RpcResult: + config: dict[str, Any] = {"encoding": encoding, **self._commitment(commitment)} + if max_supported_transaction_version is not None: + config["maxSupportedTransactionVersion"] = max_supported_transaction_version + result = self._call("getTransaction", [str(tx_sig), config]) + if result is None: + return RpcResult(None) + if not isinstance(result, dict) or "meta" not in result: + raise BridgeError("Solana RPC getTransaction returned an invalid result") + meta = result["meta"] + if meta is None: + return RpcResult(ConfirmedTransaction(TransactionWithMeta(None), int(result.get("slot", 0)))) + if not isinstance(meta, dict) or "logMessages" not in meta: + raise BridgeError("Solana RPC getTransaction returned invalid metadata") + logs = meta["logMessages"] + if logs is not None and (not isinstance(logs, list) or not all(isinstance(line, str) for line in logs)): + raise BridgeError("Solana RPC getTransaction returned invalid logs") + return RpcResult(ConfirmedTransaction(TransactionWithMeta(TransactionMeta(logs)), int(result.get("slot", 0)))) + + +class _AsyncClientAdapter: + """Drives a solana-py ``AsyncClient`` (0.36+ is async-only) synchronously on a private event-loop + thread, and supplies ``is_blockhash_valid`` (missing from solana-py) and ``send_raw_transaction`` + with our ``SendOptions`` translated to ``TxOpts``. Other methods are forwarded unchanged.""" + + def __init__(self, client: Any) -> None: + self._client = client + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._loop.run_forever, name="aleo-bridge-solana-rpc", daemon=True) + self._thread.start() + + def _run(self, coroutine: Any) -> Any: + return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() + + def __getattr__(self, name: str) -> Any: + attribute = getattr(self._client, name) + if inspect.iscoroutinefunction(attribute): + return lambda *args, **kwargs: self._run(attribute(*args, **kwargs)) + return attribute + + def is_blockhash_valid(self, blockhash: Any, commitment: str | None = None) -> Any: + from solders.commitment_config import CommitmentLevel + from solders.rpc.config import RpcContextConfig + from solders.rpc.requests import IsBlockhashValid + from solders.rpc.responses import IsBlockhashValidResp + + level = {"processed": CommitmentLevel.Processed, "confirmed": CommitmentLevel.Confirmed, + "finalized": CommitmentLevel.Finalized}[commitment or CONFIRMED] + request = IsBlockhashValid(blockhash, RpcContextConfig(commitment=level)) + return self._run(self._client._provider.make_request(request, IsBlockhashValidResp)) + + def send_raw_transaction(self, txn: bytes, opts: Any = None) -> Any: + try: + from solana.rpc.models import TxOpts + except ImportError: # solana-py < 0.36 kept TxOpts in solana.rpc.types + try: + from solana.rpc.types import TxOpts + except ImportError as exc: + raise MissingExtraError("solana", "solana-py AsyncClient transport") from exc + opts = opts or SendOptions() + tx_opts = TxOpts(skip_confirmation=True, skip_preflight=bool(opts.skip_preflight), preflight_commitment=str(opts.preflight_commitment)) + return self._run(self._client.send_raw_transaction(bytes(txn), tx_opts)) + + +@runtime_checkable +class SolanaSigner(Protocol): + """solana-py's signer shape: a solders ``Keypair`` or any wallet exposing these two methods.""" + + def pubkey(self) -> Any: ... + + def sign_message(self, message: bytes) -> Any: ... + + +def keypair_from_private_key(private_key: str | bytes) -> Any: + """Parse a Solana secret: base58 (Phantom export), a JSON array of 64 ints (solana-cli ``id.json``), + 64 raw bytes (seed ‖ pubkey) or a 32-byte seed.""" + libs = _libs() + if isinstance(private_key, (bytes, bytearray, memoryview)): + raw = bytes(private_key) + else: + text = private_key.strip() + if text.startswith("["): + try: + values = json.loads(text) + except ValueError as exc: + raise ConfigurationError("Solana private key JSON array is malformed; expected the 64 integers of a solana-cli id.json") from exc + if not isinstance(values, list) or not all(isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255 for v in values): + raise ConfigurationError("Solana private key JSON array must hold integers 0–255") + raw = bytes(values) + else: + try: + return libs.Keypair.from_base58_string(text) + except Exception as exc: # solders raises its own parse error types + raise ConfigurationError("Solana private key is not a valid base58 64-byte secret") from exc + if len(raw) == 64: + return libs.Keypair.from_bytes(raw) + if len(raw) == 32: + return libs.Keypair.from_seed(raw) + raise ConfigurationError(f"Solana private key must be 64 bytes (seed || pubkey) or a 32-byte seed, got {len(raw)}") + + +class Solana: + """Solana transport plus an optional signer (spec §3.2). + + ``Solana(rpc_url)`` builds ``SolanaRpcClient(rpc_url, commitment="confirmed")``; + ``Solana(client=…)`` reuses a caller-configured client: anything with the solana-py read/send + method surface (its own commitment, timeout, headers), or a solana-py ``AsyncClient``, which is + driven synchronously through :class:`_AsyncClientAdapter`. Exactly one of ``rpc_url``/``client`` + may be given; at most one of ``signer``/``private_key``. A connection without a signer is + read-only (quotes and status reads work, ``send`` does not). + """ + + def __init__(self, rpc_url: str | None = None, *, client: Any = None, signer: Any = None, + private_key: str | bytes | None = None) -> None: + if rpc_url is not None and client is not None: + raise ConfigurationError("Solana(): pass rpc_url or client, not both") + if signer is not None and private_key is not None: + raise ConfigurationError("Solana(): pass signer or private_key, not both") + if client is None: + _libs() # SolanaRpcClient returns solders Hash/Signature values + rpc_url = rpc_url or DEFAULT_SOLANA_RPC_URL + client = SolanaRpcClient(rpc_url, commitment=CONFIRMED) + elif inspect.iscoroutinefunction(getattr(client, "get_balance", None)): + client = _AsyncClientAdapter(client) # solana-py ≥ 0.36 AsyncClient + self._rpc_url = rpc_url + self._client = client + if private_key is not None: + signer = keypair_from_private_key(private_key) + if signer is not None and not (callable(getattr(signer, "pubkey", None)) and callable(getattr(signer, "sign_message", None))): + raise ConfigurationError("Solana signer must expose pubkey() and sign_message(bytes) — a solders Keypair or a solana-py Signer") + self._signer = signer + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "Solana | None": + """Private key from ``SOLANA_PRIVATE_KEY`` else ``BRIDGE_SOLANA_PRIVATE_KEY`` (the user's shell + exports the latter); RPC from ``SOLANA_RPC_URL`` else ``BRIDGE_LIVE_SOLANA_RPC_URL`` else the + default. A key (either name) → signing connection; URL alone → read-only; neither → ``None``.""" + env = os.environ if env is None else env + key = env.get("SOLANA_PRIVATE_KEY") or env.get("BRIDGE_SOLANA_PRIVATE_KEY") + url = env.get("SOLANA_RPC_URL") or env.get("BRIDGE_LIVE_SOLANA_RPC_URL") or None + if key: + return cls(url, private_key=key) + if url: + return cls(url) + return None + + @property + def client(self) -> Any: + return self._client + + @property + def signer(self) -> Any: + return self._signer + + @property + def rpc_url(self) -> str | None: + return self._rpc_url + + @property + def can_sign(self) -> bool: + return self._signer is not None + + @property + def pubkey(self) -> Any: + if self._signer is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") + return self._signer.pubkey() + + @property + def address(self) -> str | None: + return None if self._signer is None else str(self._signer.pubkey()) + + def sign_message(self, message: bytes) -> Any: + """Fee-payer signature over compiled message bytes (``to_bytes_versioned`` for v0 messages).""" + if self._signer is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") + return self._signer.sign_message(bytes(message)) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 2a357892..831e632b 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -151,9 +151,17 @@ def test_from_env_side_chain_variables(monkeypatch, tmp_path): assert bridge.ethereum.address == EthAccount.from_key("0x" + "11" * 32).address monkeypatch.delenv("EVM_PRIVATE_KEY") monkeypatch.delenv("ETHEREUM_RPC_URL") - monkeypatch.setenv("SOLANA_PRIVATE_KEY", "5" * 88) - with pytest.raises(MissingExtraError, match="solana"): # plan 3 makes this construct a Solana connection - Bridge.from_env() + pytest.importorskip("solders") + from solders.keypair import Keypair + + from aleo_bridge._base58 import b58encode + from aleo_bridge.sol import Solana + + solana_key = Keypair() + monkeypatch.setenv("SOLANA_PRIVATE_KEY", b58encode(bytes(solana_key))) + bridge = Bridge.from_env() # plan 3/4 (Task 4): real Solana connection now constructed + assert isinstance(bridge.solana, Solana) + assert bridge.solana.can_sign and bridge.solana.address == str(solana_key.pubkey()) monkeypatch.delenv("SOLANA_PRIVATE_KEY") monkeypatch.setenv("BRIDGE_CHECKPOINT_DIR", str(tmp_path / "cp")) bridge = Bridge.from_env() # plan 4: FileCheckpointStore now wired diff --git a/bridge-sdk/tests/test_sol_connection.py b/bridge-sdk/tests/test_sol_connection.py new file mode 100644 index 00000000..49f32525 --- /dev/null +++ b/bridge-sdk/tests/test_sol_connection.py @@ -0,0 +1,195 @@ +import importlib +import json +import sys + +import pytest + +from aleo_bridge import sol +from aleo_bridge._base58 import b58encode +from aleo_bridge.errors import ConfigurationError, MissingExtraError + + +class _Reader: + """Stands in for a caller-configured solana-py Client; never called in this file.""" + + +def _block_solders_and_solana(monkeypatch): + """Block ``solders``/``solana`` for the duration of the test, reverted automatically by + ``monkeypatch``. ``_libs()`` imports submodules (``from solders.hash import Hash``, ...), and + once any earlier test in the run has imported those submodules for real, Python's import + machinery resolves them straight from ``sys.modules`` without re-checking the (now ``None``) + top-level package — so a plain ``sys.modules["solders"] = None`` only blocks a *first* import. + Purging every already-cached ``solders``/``solana`` submodule first makes the block work + regardless of test order.""" + for name in list(sys.modules): + if name == "solders" or name.startswith("solders.") or name == "solana" or name.startswith("solana."): + monkeypatch.delitem(sys.modules, name, raising=False) + monkeypatch.setitem(sys.modules, "solders", None) + monkeypatch.setitem(sys.modules, "solana", None) + + +def test_package_imports_without_solders_or_solana(monkeypatch): + """``import aleo_bridge`` and ``from aleo_bridge import Solana`` must work with solders/solana + absent; only the first call that actually needs them raises MissingExtraError. Mirrors + test_import_without_web3.py's monkeypatch-and-revert pattern so the blocked modules and the + reimported aleo_bridge never leak into tests that run after this one.""" + _block_solders_and_solana(monkeypatch) + for name in list(sys.modules): + if name.startswith("aleo_bridge"): + monkeypatch.delitem(sys.modules, name) + + pkg = importlib.import_module("aleo_bridge") + assert pkg.Solana is not None + + from aleo_bridge.errors import MissingExtraError as ReimportedMissingExtraError + + with pytest.raises(ReimportedMissingExtraError) as exc_info: + pkg.Solana() + assert "aleo-bridge-sdk[solana]" in str(exc_info.value) + + +def test_read_only_connection_needs_no_solana_extra(monkeypatch): + monkeypatch.setattr(sol, "_LIBS", None) + _block_solders_and_solana(monkeypatch) + conn = sol.Solana(client=_Reader()) + assert conn.address is None and conn.can_sign is False and conn.rpc_url is None + with pytest.raises(ConfigurationError, match="read-only"): + conn.sign_message(b"payload") + with pytest.raises(ConfigurationError, match="read-only"): + conn.pubkey + with pytest.raises(MissingExtraError, match=r"aleo-bridge-sdk\[solana\]"): + sol.Solana() + with pytest.raises(MissingExtraError): + sol.Solana(client=_Reader(), private_key="[1,2,3]") + + +def test_constructor_argument_conflicts(): + with pytest.raises(ConfigurationError, match="rpc_url or client"): + sol.Solana("https://rpc.example", client=_Reader()) + pytest.importorskip("solders") + from solders.keypair import Keypair + + with pytest.raises(ConfigurationError, match="signer or private_key"): + sol.Solana(client=_Reader(), signer=Keypair(), private_key=b58encode(bytes(Keypair()))) + + +def test_default_transport_is_mainnet_beta_at_confirmed_commitment(): + pytest.importorskip("solders") + + conn = sol.Solana() + assert sol.DEFAULT_SOLANA_RPC_URL == "https://api.mainnet-beta.solana.com" + assert conn.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + assert isinstance(conn.client, sol.SolanaRpcClient) + assert conn.client.url == sol.DEFAULT_SOLANA_RPC_URL and conn.client.commitment == "confirmed" + custom = sol.Solana("https://rpc.example") + assert custom.rpc_url == "https://rpc.example" and custom.client.url == "https://rpc.example" + + +def test_async_solana_py_client_is_adapted_onto_a_private_loop(): + pytest.importorskip("solders") + from solders.hash import Hash + + class FakeProvider: + async def make_request(self, request, response_type): + return sol.RpcResult(True) + + class FakeAsyncClient: # the shape of solana-py ≥ 0.36 AsyncClient + _provider = FakeProvider() + + def __init__(self): + self.sent = [] + + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + async def send_raw_transaction(self, txn, opts=None): + self.sent.append((bytes(txn), opts)) + return sol.RpcResult("sig") + + fake = FakeAsyncClient() + conn = sol.Solana(client=fake) + assert isinstance(conn.client, sol._AsyncClientAdapter) and conn.can_sign is False + assert conn.client.get_balance(None).value == 7 # coroutine run synchronously + assert conn.client.is_blockhash_valid(Hash.default()).value is True # supplied by the adapter + pytest.importorskip("solana") + assert conn.client.send_raw_transaction(b"\x01").value == "sig" + sent_tx, tx_opts = fake.sent[0] + assert sent_tx == b"\x01" and tx_opts.skip_preflight is False and tx_opts.skip_confirmation is True + assert str(tx_opts.preflight_commitment) == "confirmed" + + +def test_private_key_forms(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + keypair = Keypair() + secret = bytes(keypair) # 64 bytes: seed || pubkey + assert len(secret) == 64 + for private_key in (b58encode(secret), json.dumps(list(secret)), " " + json.dumps(list(secret)) + "\n", secret): + conn = sol.Solana(client=_Reader(), private_key=private_key) + assert conn.address == str(keypair.pubkey()) and conn.can_sign is True + assert conn.pubkey == keypair.pubkey() + assert sol.keypair_from_private_key(secret[:32]).pubkey() == keypair.pubkey() # 32-byte seed + for bad in ("not-base58-0OIl", "[1, 2, 3]", "[1, 2, \"x\"]", "[", bytes(63)): + with pytest.raises(ConfigurationError): + sol.keypair_from_private_key(bad) + + +def test_keypair_signer_signs_the_message(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + keypair = Keypair() + conn = sol.Solana(client=_Reader(), signer=keypair) + assert conn.address == str(keypair.pubkey()) + assert conn.sign_message(b"payload") == keypair.sign_message(b"payload") + + +def test_any_object_with_pubkey_and_sign_message_is_a_signer(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + inner = Keypair() + + class RemoteSigner: # e.g. HSM / remote signing service + def pubkey(self): + return inner.pubkey() + + def sign_message(self, message: bytes): + return inner.sign_message(message) + + conn = sol.Solana(client=_Reader(), signer=RemoteSigner()) + assert isinstance(RemoteSigner(), sol.SolanaSigner) + assert conn.can_sign and conn.address == str(inner.pubkey()) + assert conn.sign_message(b"m") == inner.sign_message(b"m") + with pytest.raises(ConfigurationError, match="pubkey\\(\\) and sign_message"): + sol.Solana(client=_Reader(), signer=object()) + + +def test_from_env(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + key = b58encode(bytes(Keypair())) + assert sol.Solana.from_env({}) is None + with_key = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key}) + assert with_key is not None and with_key.can_sign and with_key.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + with_url = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key, "SOLANA_RPC_URL": "https://rpc.example"}) + assert with_url is not None and with_url.rpc_url == "https://rpc.example" + read_only = sol.Solana.from_env({"SOLANA_RPC_URL": "https://rpc.example"}) + assert read_only is not None and read_only.can_sign is False + + +def test_from_env_aliases(): + pytest.importorskip("solders") + from solders.keypair import Keypair + + key = b58encode(bytes(Keypair())) + aliased = sol.Solana.from_env({"BRIDGE_SOLANA_PRIVATE_KEY": key}) + assert aliased is not None and aliased.can_sign and aliased.rpc_url == sol.DEFAULT_SOLANA_RPC_URL + aliased_url = sol.Solana.from_env({"BRIDGE_SOLANA_PRIVATE_KEY": key, "BRIDGE_LIVE_SOLANA_RPC_URL": "https://rpc.example"}) + assert aliased_url is not None and aliased_url.rpc_url == "https://rpc.example" + primary_wins = sol.Solana.from_env({"SOLANA_PRIVATE_KEY": key, "BRIDGE_SOLANA_PRIVATE_KEY": "ignored", + "SOLANA_RPC_URL": "https://primary.example", + "BRIDGE_LIVE_SOLANA_RPC_URL": "https://alias.example"}) + assert primary_wins is not None and primary_wins.rpc_url == "https://primary.example" diff --git a/bridge-sdk/tests/test_sol_rpc.py b/bridge-sdk/tests/test_sol_rpc.py new file mode 100644 index 00000000..a0eb8c4e --- /dev/null +++ b/bridge-sdk/tests/test_sol_rpc.py @@ -0,0 +1,186 @@ +import base64 +import json + +import pytest + +pytest.importorskip("solders") +from solders.hash import Hash +from solders.keypair import Keypair +from solders.message import MessageV0, to_bytes_versioned +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.compute_budget import set_compute_unit_limit + +from aleo_bridge import sol +from aleo_bridge.errors import BridgeError + +SIG = str(Signature.from_bytes(bytes([9]) * 64)) +ADDR = "4LZtvKvBAM8Hcf5tuL5R7xYj9JC12v6ho8igDnwzo6WC" +HASH = "8YGT2pZwyZe94qBpGzWfY2TMEVcwaQ1bXAE7YAgpUaM7" + + +class FakeResponse: + def __init__(self, body, status_code=200, invalid_json=False): + self._body, self.status_code, self._invalid = body, status_code, invalid_json + + def json(self): + if self._invalid: + raise ValueError("bad json") + return self._body + + +class FakeSession: + """Answers each POST from a queue of bodies (the last repeats) and records every request.""" + + def __init__(self, *bodies, status_code=200, invalid_json=False): + self.bodies = list(bodies) or [{"jsonrpc": "2.0", "id": 1, "result": None}] + self.status_code, self.invalid_json = status_code, invalid_json + self.calls: list[dict] = [] + + def post(self, url, *, json, timeout, headers): + self.calls.append({"url": url, "json": json, "timeout": timeout, "headers": headers}) + body = self.bodies.pop(0) if len(self.bodies) > 1 else self.bodies[0] + return FakeResponse(body, self.status_code, self.invalid_json) + + def last(self): + return self.calls[-1]["json"]["method"], self.calls[-1]["json"]["params"] + + +def ok(result): + return {"jsonrpc": "2.0", "id": 1, "result": result} + + +def ctx(value): + return ok({"context": {"slot": 1}, "value": value}) + + +def client(*bodies, **kw): + session = FakeSession(*bodies, **kw) + return sol.SolanaRpcClient("http://rpc.test", session=session), session + + +def test_request_envelope_and_defaults(): + rpc, session = client(ok(42)) + assert rpc.get_block_height().value == 42 + call = session.calls[0] + assert call["url"] == "http://rpc.test" and call["timeout"] == 30.0 + assert call["json"]["jsonrpc"] == "2.0" and call["headers"]["cache-control"] == "no-cache" + assert session.last() == ("getBlockHeight", [{"commitment": "confirmed"}]) + assert rpc.commitment == "confirmed" and rpc.url == "http://rpc.test" + + +def test_latest_blockhash(): + rpc, session = client(ctx({"blockhash": HASH, "lastValidBlockHeight": 123456789})) + value = rpc.get_latest_blockhash().value + assert value.blockhash == Hash.from_string(HASH) and value.last_valid_block_height == 123456789 + assert session.last() == ("getLatestBlockhash", [{"commitment": "confirmed"}]) + rpc2, _ = client(ctx({"blockhash": "", "lastValidBlockHeight": 1})) + with pytest.raises(BridgeError, match="getLatestBlockhash returned an invalid result"): + rpc2.get_latest_blockhash() + + +def test_balance_and_rent_and_blockhash_validity(): + rpc, session = client(ctx(1_000_000_000)) + assert rpc.get_balance(Pubkey.from_string(ADDR)).value == 1_000_000_000 + assert session.last() == ("getBalance", [ADDR, {"commitment": "confirmed"}]) + rpc, session = client(ok(890_880)) + assert rpc.get_minimum_balance_for_rent_exemption(0).value == 890_880 + assert session.last() == ("getMinimumBalanceForRentExemption", [0, {"commitment": "confirmed"}]) + with pytest.raises(BridgeError, match="non-negative integer"): + rpc.get_minimum_balance_for_rent_exemption(-1) + rpc, session = client(ctx(True)) + assert rpc.is_blockhash_valid(Hash.from_string(HASH)).value is True + assert session.last() == ("isBlockhashValid", [HASH, {"commitment": "confirmed"}]) + rpc, _ = client(ctx(-1)) + with pytest.raises(BridgeError, match="getBalance returned an invalid result"): + rpc.get_balance(Pubkey.from_string(ADDR)) + + +def test_account_info(): + encoded = base64.b64encode(bytes([1, 2, 3, 4])).decode() + rpc, session = client(ctx({"data": [encoded, "base64"], "owner": ADDR, "lamports": 1, "executable": False, "rentEpoch": 0})) + account = rpc.get_account_info(Pubkey.from_string(ADDR)).value + assert account.data == bytes([1, 2, 3, 4]) and account.lamports == 1 and account.owner == ADDR + assert session.last() == ("getAccountInfo", [ADDR, {"encoding": "base64", "commitment": "confirmed"}]) + rpc, _ = client(ctx(None)) + assert rpc.get_account_info(Pubkey.from_string(ADDR)).value is None + rpc, _ = client(ctx({"data": ["abc", "base58"], "owner": ADDR, "lamports": 1})) + with pytest.raises(BridgeError, match="invalid base64 account data"): + rpc.get_account_info(Pubkey.from_string(ADDR)) + + +def test_fee_for_message_sends_the_versioned_message_bytes(): + payer = Keypair() + message = MessageV0.try_compile(payer.pubkey(), [set_compute_unit_limit(400_000)], [], Hash.from_string(HASH)) + rpc, session = client(ctx(10_000)) + assert rpc.get_fee_for_message(message).value == 10_000 + method, params = session.last() + assert method == "getFeeForMessage" + assert params == [base64.b64encode(to_bytes_versioned(message)).decode(), {"commitment": "confirmed"}] + rpc, _ = client(ctx(None)) + assert rpc.get_fee_for_message(message).value is None + + +def test_send_raw_transaction_params_and_preflight_error_details(): + rpc, session = client(ok(SIG)) + assert rpc.send_raw_transaction(b"\x01\x02\x03").value == Signature.from_string(SIG) + method, params = session.last() + assert method == "sendTransaction" + assert params == [base64.b64encode(b"\x01\x02\x03").decode(), {"encoding": "base64", "skipPreflight": False, "preflightCommitment": "confirmed"}] + rpc, session = client(ok(SIG)) + rpc.send_raw_transaction(b"\x01", sol.SendOptions(skip_preflight=True, preflight_commitment="processed")) + assert session.last()[1][1] == {"encoding": "base64", "skipPreflight": True, "preflightCommitment": "processed"} + failing = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32002, "message": "Transaction simulation failed", + "data": {"err": {"InstructionError": [0, "Custom"]}, "logs": ["Program log: insufficient lamports"]}}} + rpc, _ = client(failing) + with pytest.raises(BridgeError, match="insufficient lamports"): + rpc.send_raw_transaction(b"\x01") + + +def test_signature_statuses(): + rpc, session = client(ctx([None])) + assert rpc.get_signature_statuses([Signature.from_string(SIG)], search_transaction_history=True).value == [None] + assert session.last() == ("getSignatureStatuses", [[SIG], {"searchTransactionHistory": True}]) + rpc, _ = client(ctx([{"err": {"InstructionError": [0, "Custom"]}, "confirmationStatus": "processed", "slot": 1, "confirmations": None}])) + status = rpc.get_signature_statuses([Signature.from_string(SIG)]).value[0] + assert status.err == {"InstructionError": [0, "Custom"]} and status.confirmation_status == "processed" + rpc, _ = client(ctx([{"err": None, "confirmationStatus": "finalized", "slot": 1, "confirmations": None}])) + assert rpc.get_signature_statuses([Signature.from_string(SIG)]).value[0].confirmation_status == "finalized" + rpc, _ = client(ctx([{"err": None, "confirmationStatus": "mystery", "slot": 1}])) + with pytest.raises(BridgeError, match="unsupported confirmation status"): + rpc.get_signature_statuses([Signature.from_string(SIG)]) + rpc, _ = client(ctx([{}])) + with pytest.raises(BridgeError, match="invalid status"): + rpc.get_signature_statuses([Signature.from_string(SIG)]) + + +def test_get_transaction(): + rpc, session = client(ok({"slot": 5, "meta": {"logMessages": ["Program log: hi"]}, "transaction": {}})) + value = rpc.get_transaction(Signature.from_string(SIG), max_supported_transaction_version=0).value + assert value.slot == 5 and value.transaction.meta.log_messages == ["Program log: hi"] + assert session.last() == ("getTransaction", [SIG, {"encoding": "json", "commitment": "confirmed", "maxSupportedTransactionVersion": 0}]) + rpc, _ = client(ok(None)) + assert rpc.get_transaction(Signature.from_string(SIG)).value is None + rpc, _ = client(ok({"slot": 5, "meta": None, "transaction": {}})) + assert rpc.get_transaction(Signature.from_string(SIG)).value.transaction.meta is None + rpc, _ = client(ok({"slot": 5, "meta": {"logMessages": "not-a-list"}})) + with pytest.raises(BridgeError, match="invalid logs"): + rpc.get_transaction(Signature.from_string(SIG)) + + +def test_transport_errors_are_bridge_errors(): + rpc, _ = client({}, status_code=500) + with pytest.raises(BridgeError, match="500"): + rpc.get_block_height() + rpc, _ = client({"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Invalid param"}}) + with pytest.raises(BridgeError, match="Invalid param"): + rpc.get_block_height() + rpc, _ = client({}, invalid_json=True) + with pytest.raises(BridgeError, match="invalid JSON"): + rpc.get_block_height() + rpc, _ = client({"jsonrpc": "2.0", "id": 1}) + with pytest.raises(BridgeError, match="invalid result envelope"): + rpc.get_block_height() + rpc, _ = client(ok("1")) + with pytest.raises(BridgeError, match="invalid result"): + rpc.get_block_height() From 68e19e06995d98f4a2654966d2e44df85e9afaae Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:10:44 -0400 Subject: [PATCH 47/94] fix(bridge-sdk): DispatchId must come from the route's mailbox; xReserve source_status asserts the chain --- bridge-sdk/python/aleo_bridge/eth.py | 12 +++++++--- .../tests/test_eth_hyperlane_execute.py | 24 +++++++++++++++++++ bridge-sdk/tests/test_eth_status.py | 15 +++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 1a641c7b..e3271cc2 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -641,11 +641,16 @@ def _message_id_from_receipt(self, route: Route, receipt: Any) -> str | None: """Hyperlane Mailbox ``DispatchId(bytes32 indexed messageId)`` from a confirmed receipt; ``None`` if absent.""" from web3.logs import DISCARD - mailbox = self._contract(self._hyperlane_metadata(route).mailbox, MAILBOX_ABI) - events = mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) + Web3 = _web3().Web3 + address = self._hyperlane_metadata(route).mailbox + mailbox = self._contract(address, MAILBOX_ABI) + # process_receipt decodes by topic alone: another contract's DispatchId(bytes32) would + # otherwise be read as this transfer's message id, so filter on the emitting address first. + events = [ev for ev in mailbox.events.DispatchId().process_receipt(receipt, errors=DISCARD) + if Web3.to_checksum_address(ev["address"]) == address] if not events: return None - return _web3().Web3.to_hex(events[0]["args"]["messageId"]) # veil messageIdFromReceipt: first match wins + return Web3.to_hex(events[0]["args"]["messageId"]) # veil messageIdFromReceipt: first match wins @staticmethod def _hyperlane_protocol_state(route: Route, *, recipient_bytes32: bytes, destination_domain: int, @@ -903,6 +908,7 @@ def _observed_owner(self, plan: Plan, receipt: Receipt | None) -> str: def _xreserve_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> Receipt: q = self._xreserve_quote_from_state(route, plan, receipt) + self.assert_chain(route) owner = self._observed_owner(plan, receipt) source_tx_id = self._require_hash(receipt.source_tx_id, "xReserve source transaction id") observed = self.conn.get_receipt(source_tx_id) diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py index 37392af5..83c63261 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_execute.py +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -138,6 +138,30 @@ def two_dispatch_logs(tx): assert result.message_id == Web3.to_hex(first_id) +def test_dispatch_id_from_a_foreign_address_is_ignored(): + """``process_receipt`` decodes by topic only — a ``DispatchId`` emitted by anything other than the + route's Mailbox is another protocol's event and must read as "no message id".""" + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = lambda tx: [dispatch_id_log(WBTC, MESSAGE_ID, tx_hash=tx["hash"], log_index=1)] + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id is None and result.receipt.id == tx_hash_for(1) + assert result.receipt.status == Status.DELIVERY_PENDING and "messageId" not in result.receipt.protocol_state + + +def test_mailbox_dispatch_id_wins_over_an_earlier_foreign_one(): + """First match *among the Mailbox's own* events, not first match overall.""" + foreign, mine = bytes.fromhex("11" * 32), bytes.fromhex("22" * 32) + + def logs(tx): + return [dispatch_id_log(WBTC, foreign, tx_hash=tx["hash"], log_index=1), + dispatch_id_log(MAILBOX, mine, tx_hash=tx["hash"], log_index=2)] + + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + w3.provider.receipt_logs = logs + result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) + assert result.message_id == Web3.to_hex(mine) + + def test_missing_dispatch_id_log_keeps_tx_hash_as_id(): eth, _ = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) diff --git a/bridge-sdk/tests/test_eth_status.py b/bridge-sdk/tests/test_eth_status.py index b923a703..98c9d444 100644 --- a/bridge-sdk/tests/test_eth_status.py +++ b/bridge-sdk/tests/test_eth_status.py @@ -3,7 +3,8 @@ from web3 import Web3 from aleo_bridge import encoding -from aleo_bridge.errors import BridgeError, CheckpointInvalidError, ConfigurationError, UnsupportedRouteError +from aleo_bridge.errors import (BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, + UnsupportedRouteError) from aleo_bridge.eth import Ethereum, _plan_for from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import Receipt, Status @@ -132,6 +133,18 @@ def test_xreserve_state_validation(): read_only.source_status(plan_without_sender, no_owner) +def test_xreserve_source_confirming_asserts_the_chain_before_reading(): + """Mirrors the Hyperlane branch: a connection pointed at the wrong network must not read + receipts or logs from it.""" + w3 = fake_web3() # chain 1, route wants 11155111 + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + receipt = Receipt(id=H2, protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id=H2, + protocol_state=xreserve_state()) + with pytest.raises(ChainMismatchError): + eth.source_status(USDC_PLAN, receipt) + assert "eth_getTransactionReceipt" not in w3.provider.methods and "eth_getLogs" not in w3.provider.methods + + def test_is_delivered_reads_mailbox(): eth, w3 = mainnet(delivered={DELIVERED_ID}) assert eth.is_delivered(DELIVERED_ID) is True From 87a1adb9f60c8cc560fc30916758955addf087a7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:11:49 -0400 Subject: [PATCH 48/94] fix(bridge-sdk): a checkpoint-store failure after broadcast never loses the tx hash; build() checks plan.sender --- bridge-sdk/python/aleo_bridge/_calls.py | 35 ++++++++++++------- bridge-sdk/tests/test_evm_call.py | 45 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index c87ff6dc..759214b7 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -255,8 +255,6 @@ def __init__(self, conn: Any, *, plan: "Plan", registry: "Registry", self._steps, self._finish, self._store = steps, finish, store def _sender(self) -> str: - from .errors import ConfigurationError - sender = self._conn.require_address() if self.plan.sender: Web3 = self._conn.w3.__class__ @@ -266,10 +264,12 @@ def _sender(self) -> str: return sender def build(self) -> list[dict]: - """Unsigned transaction dicts in submission order (approvals then main). Reads only.""" - from .errors import ConfigurationError + """Unsigned transaction dicts in submission order (approvals then main). Reads only. - sender = self._conn.address or self.plan.sender + With an account configured the plan's sender must be that account (same rule ``send()`` + applies), so a mismatched plan fails here rather than producing calldata nobody can sign. + """ + sender = self._sender() if self._conn.address is not None else self.plan.sender if sender is None: raise ConfigurationError("build() needs a sender: configure a signer or set plan.sender") nonce = int(self._conn.w3.eth.get_transaction_count(sender, "pending")) @@ -277,14 +277,25 @@ def build(self) -> list[dict]: "chainId": self._conn.chain_id, "nonce": nonce + i} for i, step in enumerate(self._steps(sender))] - def _checkpoint(self, result: R, on_checkpoint: Callable[["Checkpoint"], None] | None) -> None: + def _checkpoint(self, result: R, on_checkpoint: Callable[["Checkpoint"], None] | None, tx_hash: str) -> None: + """Emit the checkpoint for a just-broadcast *tx_hash* to the caller first, then the store. + + The caller's callback runs before the store because the transaction is already on the wire: + if persistence fails, the hash must still have reached the one channel that can act on it. + A store failure is then fatal and names the hash — losing it silently would strand funds. + """ from .checkpoint import create_checkpoint checkpoint = create_checkpoint(self.plan, result.receipt, self._registry) # type: ignore[attr-defined] - if self._store is not None: - self._store.save(checkpoint) if on_checkpoint is not None: - on_checkpoint(checkpoint) + on_checkpoint(checkpoint) # the caller's own callback: errors are theirs + if self._store is not None: + try: + self._store.save(checkpoint) + except Exception as exc: # noqa: BLE001 — any store backend failure + raise BridgeError( + f"Transaction {tx_hash} WAS broadcast but its checkpoint {checkpoint.id} could not be saved " + f"({exc}); record the transaction hash before retrying — resending would double-spend") from exc def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, on_checkpoint: Callable[["Checkpoint"], None] | None = None) -> R: @@ -300,7 +311,7 @@ def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_second if step.kind == "approve": approvals.append(tx_hash) pending = self._finish(EvmOutcome("SOURCE_APPROVAL_PENDING", sender, tuple(approvals), None, None)) - self._checkpoint(pending, on_checkpoint) + self._checkpoint(pending, on_checkpoint, tx_hash) if not wait: return pending receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) @@ -309,7 +320,7 @@ def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_second _assert_evm_success(receipt, tx_hash) continue pending = self._finish(EvmOutcome("SOURCE_CONFIRMING", sender, tuple(approvals), tx_hash, None)) - self._checkpoint(pending, on_checkpoint) + self._checkpoint(pending, on_checkpoint, tx_hash) if not wait: return pending receipt = self._conn.wait_for_receipt(tx_hash, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) @@ -317,7 +328,7 @@ def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_second return pending _assert_evm_success(receipt, tx_hash) confirmed = self._finish(EvmOutcome("CONFIRMED", sender, tuple(approvals), tx_hash, receipt)) - self._checkpoint(confirmed, on_checkpoint) + self._checkpoint(confirmed, on_checkpoint, tx_hash) return confirmed raise BridgeError("EvmCall has no main step") diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index cb61cda8..04177aef 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -178,6 +178,51 @@ def test_plan_sender_must_match_connected_account(): assert w3.provider.sent == [] +def test_build_rejects_a_plan_sender_that_is_not_the_connected_account(): + w3 = fake_web3() + call, _ = make_call(w3, sender="0x0000000000000000000000000000000000000001") + with pytest.raises(ConfigurationError, match="does not match connected account"): + call.build() + assert w3.provider.sent == [] + + +class ExplodingStore: + """A checkpoint store whose disk is full / read-only.""" + + def __init__(self): + self.attempts = [] + + def save(self, checkpoint): + self.attempts.append(checkpoint) + raise OSError("read-only file system") + + def load(self, checkpoint_id): # pragma: no cover - never reached + return None + + def list(self): # pragma: no cover - never reached + return [] + + def delete(self, checkpoint_id): # pragma: no cover - never reached + return None + + +def test_store_failure_after_broadcast_reports_the_tx_hash_and_never_hides_it(): + """The transaction is already on the wire: the caller's callback must have run first, the + error must name the hash and the checkpoint, and no receipt poll may follow the failure.""" + w3 = fake_web3() + store = ExplodingStore() + call, _ = make_call(w3, store=store, approvals=0) + seen = [] + with pytest.raises(BridgeError) as exc: + call.send(on_checkpoint=seen.append, poll_seconds=0.001) + message = str(exc.value) + assert tx_hash_for(1) in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [tx_hash_for(1)] # callback ran before the store + assert [cp.id for cp in store.attempts] == [tx_hash_for(1)] + assert len(w3.provider.sent) == 1 # broadcast happened exactly once + assert "eth_getTransactionReceipt" not in w3.provider.methods # nothing polled after the failure + + def test_bound_store_saves_every_checkpoint(tmp_path): w3 = fake_web3() store = FileCheckpointStore(tmp_path) From a3d3c5d276c735532fc59908a12623cbe3e01ab7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:16:44 -0400 Subject: [PATCH 49/94] feat(bridge-sdk): plan-driven transfer_remote/deposit_usdc and quote entry points --- bridge-sdk/python/aleo_bridge/eth.py | 167 +++++++++++++++--- .../tests/test_eth_hyperlane_execute.py | 42 ++++- bridge-sdk/tests/test_eth_xreserve_execute.py | 35 +++- 3 files changed, 214 insertions(+), 30 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index e3271cc2..6fa231d6 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -8,7 +8,7 @@ import os import re -from dataclasses import dataclass +from dataclasses import dataclass, fields from typing import Any, Mapping from . import encoding @@ -360,6 +360,62 @@ def _route_for_plan(self, plan: Plan) -> Route: raise RouteUnavailableError(f"Route is not executable: {route.id}") return route + def _plan_route(self, plan: Plan, protocol: str) -> Route: + """Re-resolve a caller-supplied ``Plan``'s route by id (never trust plan-carried addresses).""" + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {self.registry.version}") + try: + route = self.registry.route(plan.route_id) + except RouteNotFoundError as exc: + raise RouteUnavailableError(f"Plan route {plan.route_id} is not in registry {self.registry.version}") from exc + if route.protocol != protocol: + raise RouteUnavailableError(f"{route.id} is a {route.protocol} route, not a {protocol} one") + if route.availability != "active": + raise RouteUnavailableError(f"Route is not executable ({route.availability}): {route.id}") + return route + + def _plan_sender(self, plan: Plan, *, require_signer: bool) -> str: + """``plan.sender`` as a checksummed EVM address, bound to the connected account when there is one.""" + Web3 = _web3().Web3 + sender = plan.sender + if not isinstance(sender, str) or not Web3.is_address(sender) or Web3.to_checksum_address(sender) != sender: + raise BridgeError(f"Plan sender must be a checksummed EVM address; got {sender!r}") + connected = self.conn.require_address() if require_signer else self.conn.address + if connected is not None and sender != connected: # the rule EvmCall.send() applies + raise ConfigurationError(f"Prepared sender {sender} does not match connected account {connected}") + return sender + + def _assert_plan_matches(self, plan: Plan, route: Route, *, sender: str, recipient: str, amount_atomic: int, + mint_mode: str) -> None: + """The plan must be exactly what this module would have prepared for the same transfer.""" + rebuilt = _plan_for(self.registry, route, amount_atomic=amount_atomic, recipient=recipient, sender=sender, + mint_mode=mint_mode) + for field in fields(Plan): + mine, theirs = getattr(rebuilt, field.name), getattr(plan, field.name) + if mine != theirs: + raise BridgeError(f"plan does not match the requested transfer: {field.name} is {theirs!r} " + f"but this transfer prepares {mine!r}") + + def _from_plan(self, plan: Plan, protocol: str, *, recipient: str | None, amount: Any, amount_atomic: int | None, + mint_mode: str | None, require_signer: bool) -> tuple[Route, str, str, int, str]: + """Validate ``plan=`` and return ``(route, sender, recipient, amount_atomic, mint_mode)``. + + Explicit ``recipient``/``amount``/``mint_mode`` arguments override the plan's own values and are + then caught by the field-by-field equality check, so a plan can never silently disagree with + the call that carries it. + """ + route = self._plan_route(plan, protocol) + sender = self._plan_sender(plan, require_signer=require_signer) + recipient = plan.recipient if recipient is None else recipient + mint_mode = plan.mint_mode if mint_mode is None else mint_mode + if amount is None and amount_atomic is None: + amount_atomic = plan.amount_atomic + atomic = self._amount_atomic(route, amount, amount_atomic) + self._assert_plan_matches(plan, route, sender=sender, recipient=recipient, amount_atomic=atomic, + mint_mode=mint_mode) + return route, sender, recipient, atomic, mint_mode + def assert_chain(self, route: Route) -> None: expected = int(route.metadata["sourceChainId"]) actual = self.conn.chain_id @@ -473,21 +529,36 @@ def _quote_hyperlane(self, route: Route, recipient_bytes32: bytes, amount_atomic amount_atomic, native_value, native_value, token_amount, allowance, meta.requires_approval_reset) - def quote_transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, - route: Route | None = None, sender: str | None = None) -> EvmHyperlaneQuote: + def quote_transfer_remote(self, asset: Any = None, recipient: str | None = None, *, amount: Any = None, + amount_atomic: int | None = None, route: Route | None = None, sender: str | None = None, + plan: Plan | None = None) -> EvmHyperlaneQuote: """Quote an Ethereum → Aleo Hyperlane transfer without signing. Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so ``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): ``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + + ``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and + amount, and is validated against the live registry. It is mutually exclusive with + ``asset=``/``route=``/``sender=``. """ - route = route or self._hyperlane_route(self._asset(asset)) - if route.protocol != "hyperlane": - raise BridgeError(f"{route.id} is not a Hyperlane route; use quote_deposit_usdc for xReserve") - atomic = self._amount_atomic(route, amount, amount_atomic) - recipient32 = self._recipient_bytes32(route, recipient) - owner = self._owner(sender) + if plan is not None: + if asset is not None or route is not None or sender is not None: + raise ValueError("Pass plan= or asset=/route=/sender=, not both") + route, owner, recipient, atomic, _ = self._from_plan( + plan, "hyperlane", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=None, require_signer=False) + recipient32 = self._recipient_bytes32(route, recipient) + else: + if asset is None and route is None: + raise ValueError("quote_transfer_remote needs asset=, route= or plan=") + route = route or self._hyperlane_route(self._asset(asset)) + if route.protocol != "hyperlane": + raise BridgeError(f"{route.id} is not a Hyperlane route; use quote_deposit_usdc for xReserve") + atomic = self._amount_atomic(route, amount, amount_atomic) + recipient32 = self._recipient_bytes32(route, recipient) + owner = self._owner(sender) q = self._quote_hyperlane(route, recipient32, atomic, owner) destination = self.registry.asset(route.destination_asset_id) plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner) @@ -613,19 +684,33 @@ def _quote_xreserve(self, route: Route, recipient: str, amount_atomic: int, owne balance_atomic=balance, allowance_atomic=allowance, bridge_program=meta.bridge_program, wrapper_program=meta.wrapper_program) - def quote_deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, - mint_mode: str = "public", secret_nonce: str = "0scalar", - sender: str | None = None, route: Route | None = None) -> EvmXReserveQuote: + def quote_deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str | None = None, secret_nonce: str = "0scalar", + sender: str | None = None, route: Route | None = None, + plan: Plan | None = None) -> EvmXReserveQuote: """Quote a USDC → USDCx xReserve deposit without signing. Checks the 2 USDC minimum, derives the 65-byte hook (``public``/``record``/``private``; private commits ``recipient`` with ``secret_nonce`` via BHP256) and the wire recipient (the shielded wrapper program's address for ``private``), and reads the depositor's USDC balance and xReserve allowance. ``secret_nonce`` is never stored by the SDK. + ``mint_mode`` defaults to ``plan.mint_mode`` when a plan is given, else ``"public"``. + + ``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient, amount + and mint mode, and is validated against the live registry. It is mutually exclusive with + ``route=``/``sender=``. """ - route = route or self._xreserve_route() - atomic = self._amount_atomic(route, amount, amount_atomic) - owner = self._owner(sender) + if plan is not None: + if route is not None or sender is not None: + raise ValueError("Pass plan= or route=/sender=, not both") + route, owner, recipient, atomic, mint_mode = self._from_plan( + plan, "xreserve", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=mint_mode, require_signer=False) + else: + route = route or self._xreserve_route() + mint_mode = "public" if mint_mode is None else mint_mode + atomic = self._amount_atomic(route, amount, amount_atomic) + owner = self._owner(sender) q = self._quote_xreserve(route, recipient, atomic, owner, mint_mode, secret_nonce) destination = self.registry.asset(route.destination_asset_id) plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=owner, mint_mode=mint_mode) @@ -681,8 +766,8 @@ def _hyperlane_result(self, route: Route, q: "_HyperlaneQuote", outcome: EvmOutc return DispatchReceipt(transaction_id=outcome.source_tx_id or approvals[-1], route_id=route.id, message_id=message_id, amount_atomic=q.amount_atomic, receipt=receipt) - def transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, - amount_atomic: int | None = None) -> EvmCall[DispatchReceipt]: + def transfer_remote(self, asset: Any = None, recipient: str | None = None, *, amount: Any = None, + amount_atomic: int | None = None, plan: Plan | None = None) -> EvmCall[DispatchReceipt]: """Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the @@ -690,12 +775,25 @@ def transfer_remote(self, asset: Any, recipient: str, *, amount: Any = None, reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes send the fee only. Each hash is checkpointed before polling; a timeout returns a pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + + ``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is + re-resolved by id against the live registry, the sender must be the connected account, and + the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. """ - route = self._hyperlane_route(self._asset(asset)) - sender = self.conn.require_address() - atomic = self._amount_atomic(route, amount, amount_atomic) + if plan is not None: + if asset is not None: + raise ValueError("Pass plan= or asset=, not both") + route, sender, recipient, atomic, _ = self._from_plan( + plan, "hyperlane", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=None, require_signer=True) + else: + if asset is None: + raise ValueError("transfer_remote needs asset= or plan=") + route = self._hyperlane_route(self._asset(asset)) + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender) recipient32 = self._recipient_bytes32(route, recipient) - plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender) latest: dict[str, _HyperlaneQuote] = {} def steps(owner: str) -> list[EvmStep]: @@ -791,8 +889,9 @@ def _xreserve_result(self, route: Route, q: _XReserveQuote, outcome: EvmOutcome, mint_mode=mint_mode, intended_recipient=intended_recipient)) return DepositReceipt(transaction_id=rid, route_id=route.id, message_hash="", nonce="", receipt=receipt) - def deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int | None = None, - mint_mode: str = "public", secret_nonce: str = "0scalar") -> EvmCall[DepositReceipt]: + def deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amount_atomic: int | None = None, + mint_mode: str | None = None, secret_nonce: str = "0scalar", + plan: Plan | None = None) -> EvmCall[DepositReceipt]: """Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). ``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private @@ -800,12 +899,24 @@ def deposit_usdc(self, recipient: str, *, amount: Any = None, amount_atomic: int run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, which the SDK never stores). Approves exactly the amount only when the allowance is short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` - carries Circle's message hash (receipt id) and the deposit nonce. + carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to + ``plan.mint_mode`` when a plan is given, else ``"public"``. + + ``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is + re-resolved by id against the live registry, the sender must be the connected account, and + the plan must equal what this call would have prepared itself. ``secret_nonce`` is never + part of a plan, so a private deposit must still pass the same one it was quoted with. """ - route = self._xreserve_route() - sender = self.conn.require_address() - atomic = self._amount_atomic(route, amount, amount_atomic) - plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender, mint_mode=mint_mode) + if plan is not None: + route, sender, recipient, atomic, mint_mode = self._from_plan( + plan, "xreserve", recipient=recipient, amount=amount, amount_atomic=amount_atomic, + mint_mode=mint_mode, require_signer=True) + else: + route = self._xreserve_route() + mint_mode = "public" if mint_mode is None else mint_mode + sender = self.conn.require_address() + atomic = self._amount_atomic(route, amount, amount_atomic) + plan = _plan_for(self.registry, route, amount_atomic=atomic, recipient=recipient, sender=sender, mint_mode=mint_mode) latest: dict[str, _XReserveQuote] = {} def steps(owner: str) -> list[EvmStep]: diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py index 83c63261..20c0be58 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_execute.py +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -1,9 +1,11 @@ +import dataclasses + import pytest from eth_account import Account from eth_utils import keccak from web3 import Web3 -from aleo_bridge.errors import ConfigurationError +from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DispatchReceipt, Status from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge, tx_hash_for @@ -11,6 +13,8 @@ KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +OTHER_ALEO = "aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px" +OTHER = "0x0000000000000000000000000000000000000009" ALEO_BYTES32 = "0xb102e0d37e02ec5dbba2460287ac07ef7ea8ee636392ce235402308299901811" ETH_ROUTER = "0x38D447694f5c1f773ae3132cf93bF30B7Ec1Fa5A" WBTC, WBTC_ROUTER = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", "0x20CDC85778b732073F7EecEF3DF25c0d310f8772" @@ -176,6 +180,42 @@ def test_build_lists_approval_then_dispatch_without_sending(): assert [t["value"] for t in txs] == [0, 50_000] and w3.provider.sent == [] +def test_plan_driven_transfer_is_identical_to_the_asset_driven_one(): + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + quote = eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000) + by_asset = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).build() + by_plan = eth.transfer_remote(plan=quote.plan).build() + assert by_plan == by_asset and len(by_plan) == 2 + assert eth.quote_transfer_remote(plan=quote.plan) == quote # the quote round-trips through its own plan + assert w3.provider.sent == [] + + +def test_plan_driven_transfer_rejects_a_tampered_stale_or_foreign_plan(): + """Nothing in a caller-supplied plan is trusted, and every rejection happens before any RPC.""" + eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) + plan = eth.quote_transfer_remote("wbtc", ALEO, amount_atomic=100_000).plan + w3.provider.methods.clear() + with pytest.raises(BridgeError, match="plan does not match the requested transfer: amount"): + eth.transfer_remote(plan=dataclasses.replace(plan, amount_atomic=99_999)) + with pytest.raises(BridgeError, match="plan does not match the requested transfer: recipient"): + eth.transfer_remote(recipient=OTHER_ALEO, plan=plan) # explicit argument vs the plan's own value + with pytest.raises(RegistryVersionMismatchError): + eth.transfer_remote(plan=dataclasses.replace(plan, registry_version="0000-00-00.stale")) + with pytest.raises(RouteUnavailableError, match="not a hyperlane one"): + eth.transfer_remote(plan=dataclasses.replace(plan, route_id="xreserve:ethereum/usdc->aleo/usdcx")) + with pytest.raises(RouteUnavailableError): + eth.transfer_remote(plan=dataclasses.replace(plan, route_id="hyperlane:nowhere/nothing->aleo/eth")) + with pytest.raises(ConfigurationError, match="does not match connected account"): + eth.transfer_remote(plan=dataclasses.replace(plan, sender=OTHER)) + with pytest.raises(BridgeError, match="checksummed EVM address"): + eth.transfer_remote(plan=dataclasses.replace(plan, sender=None)) + with pytest.raises(ValueError, match="not both"): + eth.transfer_remote("wbtc", plan=plan) + with pytest.raises(ValueError, match="not both"): + eth.quote_transfer_remote(plan=plan, sender=ACCT.address) + assert w3.provider.methods == [] and w3.provider.sent == [] + + def test_read_only_connection_cannot_transfer(): w3 = fake_web3(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) eth = make_bridge(ethereum=Ethereum(w3=w3)).eth diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py index 0752c464..2735a429 100644 --- a/bridge-sdk/tests/test_eth_xreserve_execute.py +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -1,3 +1,4 @@ +import dataclasses import json import pytest @@ -7,7 +8,7 @@ from web3 import Web3 from aleo_bridge import encoding -from aleo_bridge.errors import BridgeError +from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DepositReceipt, Status from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge, tx_hash_for @@ -17,6 +18,7 @@ ALEO = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442" +OTHER = "0x0000000000000000000000000000000000000009" REMOTE_TOKEN = bytes.fromhex("b143ed52c774cd1d4a519d0e796f15916be5a9e1d45edcd9852dd23f68f53401") APPROVE = keccak(text="approve(address,uint256)")[:4].hex() DEPOSIT = keccak(text="depositToRemote(uint256,uint32,bytes32,address,uint256,bytes)")[:4].hex() @@ -136,6 +138,37 @@ def test_timeouts_return_pending_receipts(): assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1), "hookData": "0x" + "00" * 65}] +def test_plan_driven_deposit_is_identical_to_the_recipient_driven_one(): + eth, w3 = setup(allowance=5_000_000) + quote = eth.quote_deposit_usdc(ALEO, amount="2", mint_mode="record") + by_recipient = eth.deposit_usdc(ALEO, amount="2", mint_mode="record").build() + by_plan = eth.deposit_usdc(plan=quote.plan).build() # mint_mode comes from the plan + assert by_plan == by_recipient and len(by_plan) == 1 and by_plan[0]["data"][2:10] == DEPOSIT + assert eth.quote_deposit_usdc(plan=quote.plan) == quote + assert w3.provider.sent == [] + + +def test_plan_driven_deposit_rejects_a_tampered_stale_or_foreign_plan(): + eth, w3 = setup(allowance=5_000_000) + plan = eth.quote_deposit_usdc(ALEO, amount="2").plan + w3.provider.methods.clear() + with pytest.raises(BridgeError, match="plan does not match the requested transfer: amount"): + eth.deposit_usdc(plan=dataclasses.replace(plan, amount_atomic=2_000_001)) + with pytest.raises(BridgeError, match="plan does not match the requested transfer: steps"): + eth.deposit_usdc(plan=dataclasses.replace(plan, mint_mode="private")) # steps still say "protocol" + with pytest.raises(BridgeError, match="plan does not match the requested transfer: mint_mode"): + eth.deposit_usdc(mint_mode="record", plan=plan) + with pytest.raises(RegistryVersionMismatchError): + eth.deposit_usdc(plan=dataclasses.replace(plan, registry_version="0000-00-00.stale")) + with pytest.raises(RouteUnavailableError, match="not a xreserve one"): + eth.deposit_usdc(plan=dataclasses.replace(plan, route_id="hyperlane:ethereum/eth->aleo/eth")) + with pytest.raises(ConfigurationError, match="does not match connected account"): + eth.deposit_usdc(plan=dataclasses.replace(plan, sender=OTHER)) + with pytest.raises(ValueError, match="not both"): + eth.quote_deposit_usdc(ALEO, amount="2", sender=ACCT.address, plan=plan) + assert w3.provider.methods == [] and w3.provider.sent == [] + + def test_reverted_deposit_raises(): eth, w3 = setup(allowance=5_000_000) w3.provider.reverted.add(tx_hash_for(1)) From a1aa97649ec8b30acd5011733cb589147475614d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:19:47 -0400 Subject: [PATCH 50/94] fix(bridge-sdk): bounded, chunked and wrapped recovery log scans --- bridge-sdk/python/aleo_bridge/eth.py | 59 ++++++++++++++++++++++++-- bridge-sdk/tests/fakes/fake_web3.py | 28 +++++++++++-- bridge-sdk/tests/test_eth_recover.py | 63 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 6fa231d6..38f45a9e 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -34,6 +34,29 @@ def _web3(): _HASH_RE = re.compile(r"^0x[0-9a-fA-F]{64}$") +LOG_SCAN_CHUNK_BLOCKS = 5_000 +"""Default block span per ``eth_getLogs`` request during recovery scans. + +Public RPC endpoints cap the range (and the result size) of a single ``eth_getLogs``; an +unbounded ``{"fromBlock": n}`` filter is refused outright by most of them once ``n`` is far +enough behind the head. Recovery therefore walks the range in chunks of this many blocks. +""" + + +def _provider_errors() -> tuple[type[BaseException], ...]: + """Everything a JSON-RPC provider can throw for one ``eth_getLogs``: web3's own errors, the + ``ValueError`` older/raw providers raise for a JSON-RPC error response, and transport errors.""" + from web3.exceptions import Web3Exception + + errors: list[type[BaseException]] = [Web3Exception, ValueError] + try: + import requests + except ImportError: # pragma: no cover - requests ships with web3's HTTP provider + pass + else: + errors.append(requests.RequestException) + return tuple(errors) + def _eth_account(): try: @@ -300,12 +323,15 @@ class _XReserveQuote: class EthModule: """``bridge.eth`` — Ethereum-origin Hyperlane and xReserve actions (reads return values, writes return ``EvmCall``).""" - def __init__(self, bridge: Any, conn: Ethereum) -> None: + def __init__(self, bridge: Any, conn: Ethereum, *, log_scan_chunk_blocks: int = LOG_SCAN_CHUNK_BLOCKS) -> None: self.bridge = bridge self.conn = conn self.registry: Registry = bridge.registry self.network: str = bridge.network # "mainnet" | "testnet" → aleo. for encoders self.chain: Chain = self.registry.chain(EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) + if int(log_scan_chunk_blocks) < 1: + raise ConfigurationError("log_scan_chunk_blocks must be at least 1") + self.log_scan_chunk_blocks = int(log_scan_chunk_blocks) # recovery eth_getLogs span; lower it for strict RPCs # -- resolution --------------------------------------------------------------------------- @@ -1084,6 +1110,33 @@ def _approval_scan_block(self, approvals: list[str]) -> int | None: block = number if block is None or number > block else block return block + def _scan_logs(self, address: str, from_block: int) -> list[Any]: + """Every log of *address* from *from_block* to the head, read in bounded ascending chunks. + + The head is read once so the scan terminates on a fixed range, and every request carries an + explicit ``fromBlock``/``toBlock``: an unbounded filter is what public RPCs reject or truncate, + and a truncated answer would silently read as "no dispatch/deposit was ever submitted". + """ + errors = _provider_errors() + chunk = self.log_scan_chunk_blocks + try: + latest = int(self.conn.w3.eth.block_number) + except errors as exc: + raise BridgeError(f"Could not read the current block number to bound a log scan of {address}: {exc}") from exc + logs: list[Any] = [] + start = from_block + while start <= latest: + end = min(start + chunk - 1, latest) + try: + logs.extend(self.conn.w3.eth.get_logs({"address": address, "fromBlock": start, "toBlock": end})) + except errors as exc: + raise BridgeError( + f"eth_getLogs failed for blocks {start}-{end} of {from_block}-{latest} on {address}: {exc}. " + f"Use a dedicated RPC endpoint, or a smaller EthModule(log_scan_chunk_blocks=...) " + f"than the current {chunk}.") from exc + start = end + 1 + return logs + def _recover_hyperlane_from_history(self, route: Route, recipient32: bytes, receipt: Receipt, approvals: list[str], *, required: bool) -> Receipt | None: """Scan router ``SentTransferRemote`` logs after the last confirmed approval; sender and router must match.""" @@ -1108,7 +1161,7 @@ def _recover_hyperlane_from_history(self, route: Route, recipient32: bytes, rece warp = self._contract(router, WARP_ROUTE_ABI) topic = Web3.keccak(text="SentTransferRemote(uint32,bytes32,uint256)") candidates: list[str] = [] - for log in self.conn.w3.eth.get_logs({"address": router, "fromBlock": from_block}): + for log in self._scan_logs(router, from_block): if not log["topics"] or bytes(log["topics"][0]) != bytes(topic): continue args = warp.events.SentTransferRemote().process_log(log)["args"] @@ -1183,7 +1236,7 @@ def _recover_xreserve_from_history(self, route: Route, plan: Plan, q: _XReserveQ "for source history verification") return None hashes: list[str] = [] - for log in self.conn.w3.eth.get_logs({"address": q.xreserve_contract, "fromBlock": from_block}): + for log in self._scan_logs(q.xreserve_contract, from_block): tx_hash = Web3.to_hex(log["transactionHash"]) if tx_hash not in hashes: hashes.append(tx_hash) diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index 99390d6a..c3339b14 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -124,7 +124,9 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N self.receipt_delay: dict[str, int] = {} # hash -> remaining polls that return None before mined self.receipt_poll_counts: dict[str, int] = {} # hash -> eth_getTransactionReceipt calls seen for it self.receipt_logs: Callable[[dict], list[dict]] = lambda tx: [] # logs for a sent tx's receipt - self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/fromBlock) + self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/from/toBlock) + self.log_filters: list[dict] = [] # raw eth_getLogs filter params, in call order + self.log_scan_errors: dict[int, str] = {} # 1-based eth_getLogs call -> JSON-RPC error message self.transactions: dict[str, dict] = {} # extra eth_getTransactionByHash answers self.receipts: dict[str, dict] = {} # extra eth_getTransactionReceipt answers self.block_number = 0x10 @@ -133,9 +135,21 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N def _ok(self, result: Any) -> dict: return {"jsonrpc": "2.0", "id": 1, "result": result} + def _block_tag(self, value: Any, default: int) -> int: + """A JSON-RPC block tag as an int; ``None``/``"latest"`` fall back to *default*.""" + if value is None or value in ("latest", "pending", "safe", "finalized"): + return default + return int(value, 16) if isinstance(value, str) else int(value) + + def _observe_block(self, block_number: int) -> None: + """Keep the head at least as high as any block the test has placed a receipt or tx in, so a + bounded (``fromBlock``..``eth_blockNumber``) log scan can actually reach that history.""" + self.block_number = max(self.block_number, block_number) + def add_receipt(self, tx_hash: str, *, status: int = 1, logs: list[dict] | None = None, block_number: int = 0x65, sender: str = ZERO_ADDRESS, to: str = ZERO_ADDRESS) -> None: """Serve a receipt for a hash the fake never accepted itself (recovery / status tests).""" + self._observe_block(block_number) self.receipts[tx_hash] = { "transactionHash": tx_hash, "status": _hex(status), "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, "transactionIndex": "0x0", "from": to_checksum_address(sender), "to": to_checksum_address(to), @@ -144,6 +158,7 @@ def add_receipt(self, tx_hash: str, *, status: int = 1, logs: list[dict] | None def add_transaction(self, tx_hash: str, *, sender: str, to: str, block_number: int = 0x65) -> None: """Serve eth_getTransactionByHash for a hash the fake never accepted itself.""" + self._observe_block(block_number) self.transactions[tx_hash] = { "hash": tx_hash, "from": to_checksum_address(sender), "to": to_checksum_address(to), "input": "0x", "value": "0x0", "blockNumber": _hex(block_number), "blockHash": BLOCK_HASH, "nonce": "0x0", "gas": "0x1", "gasPrice": "0x1", @@ -195,12 +210,17 @@ def make_request(self, method: str, params: Any) -> dict: return self._ok(self._receipt(h)) if method == "eth_getLogs": f = params[0] + self.log_filters.append(f) + message = self.log_scan_errors.get(len(self.log_filters)) + if message is not None: + return {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": message}} addr = f.get("address") addrs = {to_checksum_address(a) for a in (addr if isinstance(addr, list) else [addr])} if addr else None - raw_from = f.get("fromBlock", 0) - from_block = int(raw_from, 16) if isinstance(raw_from, str) else int(raw_from) + from_block = self._block_tag(f.get("fromBlock"), 0) + to_block = self._block_tag(f.get("toBlock"), self.block_number) return self._ok([log for log in self.history_logs - if (addrs is None or log["address"] in addrs) and int(log["blockNumber"], 16) >= from_block]) + if (addrs is None or log["address"] in addrs) + and from_block <= int(log["blockNumber"], 16) <= to_block]) if method == "eth_getTransactionByHash": h = params[0] if h in self.transactions: diff --git a/bridge-sdk/tests/test_eth_recover.py b/bridge-sdk/tests/test_eth_recover.py index f3a8b3c3..77dff2cb 100644 --- a/bridge-sdk/tests/test_eth_recover.py +++ b/bridge-sdk/tests/test_eth_recover.py @@ -114,6 +114,69 @@ def test_hyperlane_scan_ignores_other_senders_and_amounts(): assert eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()).status == Status.SOURCE_SUBMISSION_PENDING +def test_hyperlane_scan_skips_a_candidate_sent_to_another_contract(): + """A matching ``SentTransferRemote`` log whose transaction went somewhere other than the router + belongs to another call path (a batcher, a router of a different route) — never adopt it.""" + eth, w3 = mainnet_read_only() + w3.provider.add_receipt(APPROVAL, block_number=0x65) + w3.provider.history_logs.append(sent_transfer_remote_log(WBTC_ROUTER, destination=1634493807, recipient32=ALEO32, + amount=100_000, tx_hash=RECOVERED, block_number=0x66)) + w3.provider.add_transaction(RECOVERED, sender=ACCT.address, to=OTHER, block_number=0x66) + w3.provider.add_receipt(RECOVERED, logs=[dispatch_id_log(MAILBOX, RECOVERED_MESSAGE_ID, tx_hash=RECOVERED)], + sender=ACCT.address, to=OTHER, block_number=0x66) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.SOURCE_SUBMISSION_PENDING and receipt.source_tx_id is None + + +def test_recovery_log_scan_is_bounded_and_chunked(): + """Every eth_getLogs carries an explicit fromBlock/toBlock; the chunks tile the range exactly + once and stop at the head read at the start of the scan.""" + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 # the approval is 25 blocks behind the head + assert eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()).status == Status.SOURCE_SUBMISSION_PENDING + assert [(f["fromBlock"], f["toBlock"]) for f in w3.provider.log_filters] == [ + (hex(101), hex(110)), (hex(111), hex(120)), (hex(121), hex(126))] + assert all(f["address"] == [Web3.to_checksum_address(WBTC_ROUTER)] for f in w3.provider.log_filters) + + +def test_a_dispatch_in_the_last_chunk_is_still_found(): + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + dispatch_history(w3, RECOVERED, block_number=126) + receipt = eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert receipt.status == Status.DELIVERY_PENDING and receipt.source_tx_id == RECOVERED + assert len(w3.provider.log_filters) == 3 + + +def test_a_failing_log_chunk_names_the_span_it_could_not_read(): + eth, w3 = mainnet_read_only() + eth.log_scan_chunk_blocks = 10 + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 + w3.provider.log_scan_errors[2] = "query returned more than 10000 results" + with pytest.raises(BridgeError, match="blocks 111-120 of 101-126") as exc: + eth.recover_source(WBTC_PLAN, hyperlane_checkpoint()) + assert "10000 results" in str(exc.value) and "smaller" in str(exc.value) + assert len(w3.provider.log_filters) == 2 # stopped at the failing chunk + + +def test_xreserve_scan_is_chunked_too(): + w3 = fake_web3(chain_id=11155111) + eth = make_bridge(environment="testnet", ethereum=Ethereum(w3=w3)).eth + eth.log_scan_chunk_blocks = 10 + plan = _plan_for(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + cp = xreserve_checkpoint(plan, bytes(65)) + w3.provider.add_receipt(APPROVAL, block_number=101) + w3.provider.block_number = 126 + assert eth.recover_source(plan, cp).status == Status.SOURCE_SUBMISSION_PENDING + assert [(f["fromBlock"], f["toBlock"]) for f in w3.provider.log_filters] == [ + (hex(101), hex(110)), (hex(111), hex(120)), (hex(121), hex(126))] + assert all(f["address"] == [Web3.to_checksum_address(SEPOLIA_XRESERVE)] for f in w3.provider.log_filters) + + def test_hyperlane_multiple_matches_refuse_to_choose(): eth, w3 = mainnet_read_only() w3.provider.add_receipt(APPROVAL, block_number=0x65) From d8bfde178ad893fca3b179126991da63d1901a8a Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:23:50 -0400 Subject: [PATCH 51/94] fix(bridge-sdk): native fee decimals, validated mailbox lookup, matching deposit event selection and hex fullmatch --- bridge-sdk/python/aleo_bridge/eth.py | 68 +++++++++++++------ bridge-sdk/tests/test_eth_xreserve_execute.py | 57 +++++++++++++--- 2 files changed, 98 insertions(+), 27 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 38f45a9e..e362deee 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -479,8 +479,9 @@ def _erc20(self, address: str) -> Any: def _native_fee(self, amount_wei: int) -> Fee: native = [a for a in self.registry.assets(chain=self.chain.id) if a.kind == "native"] asset_id = native[0].id if native else f"{self.chain.id}/{self.chain.native_symbol.lower()}" + decimals = native[0].decimals if native else 18 # only an unlisted chain falls back to the EVM default return Fee(kind="network", chain_id=self.chain.id, asset_id=asset_id, - amount=format_decimal_amount(amount_wei, 18), estimated=True) + amount=format_decimal_amount(amount_wei, decimals), estimated=True) # -- Hyperlane quote ---------------------------------------------------------------------- @@ -862,6 +863,8 @@ def _confirmed_deposit_receipt(self, route: Route, q: _XReserveQuote, *, owner: from web3.logs import DISCARD Web3 = _web3().Web3 + # Kept even though send() already asserted success: _recover_xreserve_from_history reaches + # this with receipts nothing has checked, so the revert test must live here too. if int(receipt["status"]) == 0: raise BridgeError(f"EVM transaction reverted: {source_tx_id}") xreserve = self._contract(q.xreserve_contract, XRESERVE_ABI) @@ -869,17 +872,23 @@ def _confirmed_deposit_receipt(self, route: Route, q: _XReserveQuote, *, owner: if Web3.to_checksum_address(ev["address"]) == q.xreserve_contract] if not events: raise BridgeError("Confirmed receipt does not contain a valid DepositedToRemote event") - ev = events[-1] - a = ev["args"] - if (Web3.to_checksum_address(a["localToken"]) != q.token - or Web3.to_checksum_address(a["localDepositor"]) != owner - or int(a["value"]) != q.amount_atomic - or int(a["remoteDomain"]) != q.remote_domain - or bytes(a["remoteRecipient"]) != q.remote_recipient_bytes32 - or bytes(a["remoteToken"]) != q.remote_token_bytes32 - or int(a["maxFee"]) != q.max_fee_atomic - or bytes(a["hookData"]) != q.hook_data): + + def matches(args: Mapping[str, Any]) -> bool: + return (Web3.to_checksum_address(args["localToken"]) == q.token + and Web3.to_checksum_address(args["localDepositor"]) == owner + and int(args["value"]) == q.amount_atomic + and int(args["remoteDomain"]) == q.remote_domain + and bytes(args["remoteRecipient"]) == q.remote_recipient_bytes32 + and bytes(args["remoteToken"]) == q.remote_token_bytes32 + and int(args["maxFee"]) == q.max_fee_atomic + and bytes(args["hookData"]) == q.hook_data) + + # One transaction can batch several accounts' deposits, so take OUR event rather than the + # last one: every one of the eight canonical fields has to match for it to be ours. + ev = next((e for e in events if matches(e["args"])), None) + if ev is None: raise BridgeError("DepositedToRemote event does not match the prepared transfer") + a = ev["args"] log_index = int(ev["logIndex"]) if log_index < 0: raise BridgeError("DepositedToRemote log index is missing or invalid") @@ -965,7 +974,7 @@ def finish(outcome: EvmOutcome) -> DepositReceipt: @staticmethod def _require_hash(value: Any, what: str) -> str: - if not isinstance(value, str) or not _HASH_RE.match(value): + if not isinstance(value, str) or not _HASH_RE.fullmatch(value): raise CheckpointInvalidError(f"Receipt is missing a valid {what}") return value @@ -979,13 +988,13 @@ def _validate_hyperlane_state(self, route: Route, plan: Plan, receipt: Receipt) state = receipt.protocol_state recipient32 = encoding.aleo_address_to_bytes32(plan.recipient) if (receipt.protocol != "hyperlane" - or state.get("destinationDomain") != int(route.metadata["destinationDomain"]) + or state.get("destinationDomain") != self._hyperlane_metadata(route).destination_domain or state.get("amountAtomic") != str(plan.amount_atomic) or not isinstance(state.get("recipientBytes32"), str) or state["recipientBytes32"].lower() != "0x" + recipient32.hex()): raise CheckpointInvalidError("Hyperlane checkpoint does not match the prepared transfer") ids = state.get("approvalTxIds", []) - if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.match(i) for i in ids): + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.fullmatch(i) for i in ids): raise CheckpointInvalidError("Hyperlane checkpoint contains invalid approval transaction ids") return recipient32 @@ -1015,7 +1024,7 @@ def _xreserve_quote_from_state(self, route: Route, plan: Plan, receipt: Receipt) try: ok = (Web3.is_address(s["xReserveContract"]) and Web3.is_address(s["tokenAddress"]) and isinstance(s["sourceChainId"], int) and isinstance(s["remoteDomain"], int) - and _HASH_RE.match(s["remoteRecipientBytes32"]) is not None + and _HASH_RE.fullmatch(s["remoteRecipientBytes32"]) is not None and isinstance(s["hookData"], str) and len(s["hookData"]) == 132 and s["hookData"].startswith("0x") and str(s["amountAtomic"]).isdigit() and str(s["maxFeeAtomic"]).isdigit()) except (KeyError, TypeError): @@ -1023,7 +1032,7 @@ def _xreserve_quote_from_state(self, route: Route, plan: Plan, receipt: Receipt) if not ok: raise CheckpointInvalidError("Checkpoint contains invalid xReserve submission state") ids = s.get("approvalTxIds", []) - if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.match(i) for i in ids): + if not isinstance(ids, list) or any(not isinstance(i, str) or not _HASH_RE.fullmatch(i) for i in ids): raise CheckpointInvalidError("Checkpoint contains invalid xReserve approval transaction ids") meta = self._xreserve_metadata(route) # reuse the registry validator rather than trusting raw metadata again return _XReserveQuote( @@ -1053,7 +1062,8 @@ def _xreserve_source_status(self, route: Route, plan: Plan, receipt: Receipt) -> return receipt if int(observed["status"]) == 0: return self._failed(receipt, "sourceError", f"EVM transaction reverted: {source_tx_id}") - return self._confirmed_deposit_receipt(route, q, owner=owner, approval_tx_ids=list(receipt.protocol_state["approvalTxIds"]), + return self._confirmed_deposit_receipt(route, q, owner=owner, + approval_tx_ids=list(receipt.protocol_state.get("approvalTxIds", [])), source_tx_id=source_tx_id, receipt=observed, mint_mode=plan.mint_mode, intended_recipient=plan.recipient) @@ -1093,7 +1103,7 @@ def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: def _checkpoint_approvals(self, checkpoint: Checkpoint) -> list[str]: approvals = list((checkpoint.source or {}).get("approvalTransactionIds", [])) - if any(not isinstance(a, str) or not _HASH_RE.match(a) for a in approvals): + if any(not isinstance(a, str) or not _HASH_RE.fullmatch(a) for a in approvals): raise CheckpointInvalidError("Bridge checkpoint contains an invalid approval transaction id") return approvals @@ -1181,6 +1191,10 @@ def _recover_hyperlane_from_history(self, route: Route, recipient32: bytes, rece or Web3.to_checksum_address(tx["to"]) != router): continue if int(observed["status"]) == 0: + # Deliberately asymmetric with the xReserve scan below, and identical to veil: a + # reverted Hyperlane candidate raises (hyperlane/evm.ts) because sender+router+args + # already identify it as ours, while xreserve/evmToAleo.ts swallows a rejected + # candidate because the shared contract's logs are mostly other accounts' deposits. raise BridgeError(f"EVM transaction reverted: {tx_hash}") message_id = self._message_id_from_receipt(route, observed) state = dict(receipt.protocol_state) @@ -1330,7 +1344,23 @@ def recover_source(self, plan: Plan, checkpoint: Checkpoint, *, required: bool = raise UnsupportedRouteError(f"No Ethereum recovery for protocol {route.protocol}") def _mailbox_address(self) -> str: - for route in self.registry.routes(protocol="hyperlane", include_unavailable=True, environment=self.bridge.environment): + """The Hyperlane Mailbox deployed on this chain. + + Prefer a route that ORIGINATES here and passes the full metadata validator: its + ``mailboxAddress`` is the contract this chain's own dispatches go through, checksummed and + checked. Only if no such route exists do we fall back to any route that merely touches this + chain (whose ``mailboxAddress`` may be the remote one, and is unvalidated). + """ + routes = list(self.registry.routes(protocol="hyperlane", include_unavailable=True, + environment=self.bridge.environment)) + for route in routes: + if self.registry.asset(route.source_asset_id).chain_id != self.chain.id: + continue + try: + return self._hyperlane_metadata(route).mailbox + except (ConfigurationError, RouteUnavailableError): + continue + for route in routes: chains = {self.registry.asset(route.source_asset_id).chain_id, self.registry.asset(route.destination_asset_id).chain_id} mailbox = route.metadata.get("mailboxAddress") if self.chain.id in chains and isinstance(mailbox, str): diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py index 2735a429..da6e43f3 100644 --- a/bridge-sdk/tests/test_eth_xreserve_execute.py +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -24,16 +24,21 @@ DEPOSIT = keccak(text="depositToRemote(uint256,uint32,bytes32,address,uint256,bytes)")[:4].hex() -def deposit_logs(*, remote_token32=REMOTE_TOKEN, value_override=None, log_index=3): - """Echo the depositToRemote calldata back as a DepositedToRemote log, optionally corrupting one field.""" +def deposit_fields(tx): + """The DepositedToRemote fields the contract would emit for this depositToRemote calldata.""" + value, remote_domain, remote_recipient, local_token, max_fee, hook = decode( + ["uint256", "uint32", "bytes32", "address", "uint256", "bytes"], bytes.fromhex(tx["data"][10:])) + return {"local_token": Web3.to_checksum_address(local_token), "depositor": tx["from"], + "remote_recipient32": remote_recipient, "value": value, "remote_domain": remote_domain, + "remote_token32": REMOTE_TOKEN, "max_fee": max_fee, "hook_data": hook} + + +def deposit_logs(*, log_index=3, **overrides): + """Echo the depositToRemote calldata back as a DepositedToRemote log, optionally corrupting fields.""" def logs(tx): if tx["to"] != Web3.to_checksum_address(XRESERVE) or tx["data"][2:10] != DEPOSIT: return [] - value, remote_domain, remote_recipient, local_token, max_fee, hook = decode( - ["uint256", "uint32", "bytes32", "address", "uint256", "bytes"], bytes.fromhex(tx["data"][10:])) - return [deposited_log(XRESERVE, local_token=Web3.to_checksum_address(local_token), depositor=tx["from"], - remote_recipient32=remote_recipient, value=value_override or value, remote_domain=remote_domain, - remote_token32=remote_token32, max_fee=max_fee, hook_data=hook, tx_hash=tx["hash"], log_index=log_index)] + return [deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=log_index, **{**deposit_fields(tx), **overrides})] return logs @@ -112,7 +117,7 @@ def test_private_mode_deposits_to_wrapper_and_never_persists_the_secret(): def test_event_mismatch_or_absence_raises(): - eth, _ = setup(allowance=5_000_000, logs=deposit_logs(value_override=1)) + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(value=1)) with pytest.raises(BridgeError, match="does not match the prepared transfer"): eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) eth, _ = setup(allowance=5_000_000, logs=deposit_logs(remote_token32=bytes(32))) @@ -123,6 +128,42 @@ def test_event_mismatch_or_absence_raises(): eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) +@pytest.mark.parametrize("field, corrupted", [ + ("local_token", OTHER), + ("depositor", OTHER), + ("value", 1_999_999), + ("remote_domain", 10_003), + ("remote_recipient32", bytes(32)), + ("remote_token32", bytes(32)), + ("max_fee", 99_999), + ("hook_data", b"\x01" + bytes(64)), +]) +def test_every_re_verified_deposit_field_must_match(field, corrupted): + """All eight canonical DepositedToRemote fields are load-bearing: corrupting any one of them + alone must make the event stop counting as this transfer's deposit.""" + eth, _ = setup(allowance=5_000_000, logs=deposit_logs(**{field: corrupted})) + with pytest.raises(BridgeError, match="does not match the prepared transfer"): + eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +@pytest.mark.parametrize("ours_first", [True, False]) +def test_our_deposit_event_is_selected_among_other_accounts_deposits(ours_first): + """A batched transaction carries several accounts' deposits; ours is whichever event matches all + eight fields, not whichever happens to be last in the receipt.""" + def logs(tx): + if tx["to"] != Web3.to_checksum_address(XRESERVE) or tx["data"][2:10] != DEPOSIT: + return [] + fields = deposit_fields(tx) + ours = deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=3, **fields) + theirs = deposited_log(XRESERVE, tx_hash=tx["hash"], log_index=7, **{**fields, "depositor": OTHER}) + return [ours, theirs] if ours_first else [theirs, ours] + + eth, _ = setup(allowance=5_000_000, logs=logs) + result = eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + assert result.receipt.status == Status.ATTESTATION_PENDING + assert result.receipt.protocol_state["depositLogIndex"] == 3 # ours, whatever the order + + def test_timeouts_return_pending_receipts(): eth, w3 = setup() w3.provider.pending.add(tx_hash_for(1)) From 73591c6dce0e5843209ef233f6e77c24275e4a3f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:24:19 -0400 Subject: [PATCH 52/94] docs(bridge-sdk): note BRIDGE_LIVE_ETHEREUM_RPC_URL is an ordinary from_env RPC alias --- bridge-sdk/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 7200f193..4d42705c 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -74,6 +74,9 @@ ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg. `EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL` (aliases `BRIDGE_EVM_PRIVATE_KEY`+`BRIDGE_LIVE_ETHEREUM_RPC_URL`, used by the user's live shell/veil config; the primary variable wins when both are set), `SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`), `BRIDGE_CHECKPOINT_DIR`. +Note that `BRIDGE_LIVE_ETHEREUM_RPC_URL` is not live-test-only: ordinary `Ethereum.from_env()` / +`Bridge.from_env()` read it as an alias for `ETHEREUM_RPC_URL`, so leaving it exported points everyday +calls at that endpoint too. Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo key (mode 600). ## Tests From a8ef7035cb6c8970064951d54f5557bc310d599b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:26:43 -0400 Subject: [PATCH 53/94] refactor(bridge-sdk): promote _plan_for to _plan.build_plan with a chain-family wallet executor --- bridge-sdk/python/aleo_bridge/_plan.py | 56 ++++++++++++++++++++++++++ bridge-sdk/python/aleo_bridge/eth.py | 34 +--------------- bridge-sdk/tests/test_evm_call.py | 35 ++++++++++++++++ 3 files changed, 93 insertions(+), 32 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/_plan.py diff --git a/bridge-sdk/python/aleo_bridge/_plan.py b/bridge-sdk/python/aleo_bridge/_plan.py new file mode 100644 index 00000000..667aaa5e --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_plan.py @@ -0,0 +1,56 @@ +"""``build_plan`` — the one Plan builder every origin chain shares (brief §2.1, mirrors veil ``prepare``). + +Plan 4's ``lifecycle.prepare`` is the Tier-1 entry point; this helper is the Tier-2 path, so +``bridge.eth.*`` / ``bridge.sol.*`` calls carry a checkpointable plan without importing lifecycle. +It lives outside ``eth.py`` because the step shape is protocol- and chain-driven, not Ethereum-driven: +the wallet steps' executor comes from the source chain's family, so the same builder describes an +Ethereum, Solana or Aleo origin. +""" +from __future__ import annotations + +from .errors import BridgeError +from .registry import Asset, Registry, Route +from .types import Plan, Step +from .units import format_decimal_amount + +WALLET_EXECUTOR_BY_FAMILY = {"evm": "evm-wallet", "solana": "solana-wallet", "aleo": "aleo-wallet"} + + +def _wallet_executor(registry: Registry, source: Asset) -> str: + """Who signs the source-chain steps: the wallet of the chain the funds leave from.""" + family = registry.chain(source.chain_id).family + executor = WALLET_EXECUTOR_BY_FAMILY.get(family) + if executor is None: + raise BridgeError(f"No wallet executor for chain family {family!r} ({source.chain_id})") + return executor + + +def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipient: str, sender: str | None, + mint_mode: str = "public") -> Plan: + """Build the ``Plan`` for one bridge route (mirrors veil ``prepare`` steps, brief §2.1).""" + source: Asset = registry.asset(route.source_asset_id) + destination: Asset = registry.asset(route.destination_asset_id) + if mint_mode not in ("public", "record", "private"): + raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") + if mint_mode != "public" and route.protocol != "xreserve": + raise BridgeError("mint_mode other than public applies only to xReserve deposits to Aleo") + if amount_atomic <= 0: + raise BridgeError("amount_atomic must be positive") + wallet = _wallet_executor(registry, source) + if route.protocol == "xreserve": + steps = (Step("source-approval", "approve", wallet, False), + Step("source-deposit", "deposit", wallet, True), + Step("deposit-attestation", "wait-attestation", "protocol", False), + Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) + else: + steps = tuple([Step("source-approval", "approve", wallet, False)] if source.kind == "token" else []) + ( + Step("source-dispatch", "dispatch", wallet, True), + Step("message-delivery", "wait-delivery", "protocol", False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) + return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, + environment=route.environment, source_asset_id=source.id, destination_asset_id=destination.id, + amount=format_decimal_amount(amount_atomic, source.decimals), amount_atomic=amount_atomic, + recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) + + +__all__ = ["WALLET_EXECUTOR_BY_FAMILY", "build_plan"] diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index e362deee..dd4e7bf7 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -14,13 +14,14 @@ from . import encoding from ._calls import EvmCall, EvmOutcome, EvmStep from ._evm_abi import ERC20_ABI, EVM_CHAIN_BY_ENVIRONMENT, MAILBOX_ABI, WARP_ROUTE_ABI, XRESERVE_ABI, ZERO_ADDRESS +from ._plan import build_plan as _plan_for # kept as a module-level name: existing callers import eth._plan_for from .checkpoint import Checkpoint from .errors import (AmbiguousRouteError, BridgeError, ChainMismatchError, CheckpointInvalidError, ConfigurationError, InsufficientBalanceError, InvalidAmountError, InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, RouteUnavailableError, UnsupportedRouteError) from .registry import Asset, Chain, Registry, Route from .types import (ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, Fee, Plan, - Receipt, Status, Step) + Receipt, Status) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount @@ -202,37 +203,6 @@ def get_receipt(self, tx_hash: str) -> dict | None: return None -def _plan_for(registry: Registry, route: Route, *, amount_atomic: int, recipient: str, sender: str | None, - mint_mode: str = "public") -> Plan: - """Build the ``Plan`` for an Ethereum-origin route (mirrors veil ``prepare`` steps, brief §2.1). - - Plan 4's ``lifecycle.prepare`` is the Tier-1 entry point; this helper is the Tier-2 - path so ``bridge.eth.*`` calls carry a checkpointable plan without importing lifecycle. - """ - source: Asset = registry.asset(route.source_asset_id) - destination: Asset = registry.asset(route.destination_asset_id) - if mint_mode not in ("public", "record", "private"): - raise BridgeError(f"mint_mode must be public, record or private; got {mint_mode!r}") - if mint_mode != "public" and route.protocol != "xreserve": - raise BridgeError("mint_mode other than public applies only to xReserve deposits to Aleo") - if amount_atomic <= 0: - raise BridgeError("amount_atomic must be positive") - if route.protocol == "xreserve": - steps = (Step("source-approval", "approve", "evm-wallet", False), - Step("source-deposit", "deposit", "evm-wallet", True), - Step("deposit-attestation", "wait-attestation", "protocol", False), - Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) - else: - steps = tuple([Step("source-approval", "approve", "evm-wallet", False)] if source.kind == "token" else []) + ( - Step("source-dispatch", "dispatch", "evm-wallet", True), - Step("message-delivery", "wait-delivery", "protocol", False), - Step("destination-confirmation", "confirm-delivery", "protocol", False)) - return Plan(route_id=route.id, registry_version=registry.version, protocol=route.protocol, - environment=route.environment, source_asset_id=source.id, destination_asset_id=destination.id, - amount=format_decimal_amount(amount_atomic, source.decimals), amount_atomic=amount_atomic, - recipient=recipient, sender=sender, mint_mode=mint_mode, steps=steps) - - _REGISTRY_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index 04177aef..7126502b 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -3,6 +3,7 @@ from web3 import Web3 from aleo_bridge._calls import EvmCall, EvmOutcome, EvmStep +from aleo_bridge._plan import build_plan from aleo_bridge._evm_abi import ERC20_ABI, WARP_ROUTE_ABI from aleo_bridge.checkpoint import FileCheckpointStore from aleo_bridge.errors import BridgeError, ConfigurationError @@ -72,6 +73,40 @@ def test_plan_for_hyperlane_and_xreserve_shapes(): _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=1, recipient=ALEO, sender=None, mint_mode="private") +def test_build_plan_is_the_shared_builder_eth_re_exports(): + from aleo_bridge import _plan, eth + + assert eth._plan_for is _plan.build_plan + + +def test_build_plan_derives_the_wallet_executor_from_the_source_chain_family(): + """Ethereum-origin plans are identical to the ones the hard-coded "evm-wallet" used to produce.""" + eth_plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:ethereum/eth->aleo/eth"), + amount_atomic=100, recipient=ALEO, sender=ACCT.address) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in eth_plan.steps] == [ + ("source-dispatch", "dispatch", "evm-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + usdc_plan = build_plan(DEFAULT_REGISTRY, USDC_ROUTE, amount_atomic=2_000_000, recipient=ALEO, sender=ACCT.address) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in usdc_plan.steps] == [ + ("source-approval", "approve", "evm-wallet", False), + ("source-deposit", "deposit", "evm-wallet", True), + ("deposit-attestation", "wait-attestation", "protocol", False), + ("destination-mint", "mint", "protocol", False), + ] + + +def test_build_plan_uses_a_solana_wallet_for_a_solana_origin(): + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:solana/sol->aleo/sol"), + amount_atomic=1, recipient=ALEO, sender="11111111111111111111111111111111") + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-dispatch", "dispatch", "solana-wallet", True), # SOL is native: no approval step + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + def test_build_returns_unsigned_dicts_in_order_without_sending(): w3 = fake_web3() call, _ = make_call(w3) From 4cf603efd33d0693a24e1a13feab4a32f2cd987b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:34:57 -0400 Subject: [PATCH 54/94] =?UTF-8?q?feat(bridge-sdk):=20SolModule=20quote=20?= =?UTF-8?q?=E2=80=94=20IGP=20payment,=20network=20fee,=20rent=20and=20tota?= =?UTF-8?q?l=20lamports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bridge-sdk/python/aleo_bridge/sol.py | 151 ++++++++++++++++++++++++++ bridge-sdk/tests/fakes/fake_solana.py | 136 +++++++++++++++++++++++ bridge-sdk/tests/test_client.py | 16 ++- bridge-sdk/tests/test_sol_quote.py | 115 ++++++++++++++++++++ 4 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/fakes/fake_solana.py create mode 100644 bridge-sdk/tests/test_sol_quote.py diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 15b088d8..e9809a70 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -20,6 +20,7 @@ import requests from . import _sealevel as sl +from ._plan import build_plan from .encoding import aleo_address_to_bytes32 from .errors import ( BridgeError, @@ -29,6 +30,7 @@ InvalidAmountError, MissingExtraError, RegistryVersionMismatchError, + RouteNotFoundError, UnsupportedRouteError, ) from .registry import Route @@ -434,3 +436,152 @@ def sign_message(self, message: bytes) -> Any: if self._signer is None: raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") return self._signer.sign_message(bytes(message)) + + +def _confirmation_name(status: Any) -> str | None: + """Normalise solders' TransactionConfirmationStatus enum (or a plain string) to 'processed'|'confirmed'|'finalized'.""" + value = getattr(status, "confirmation_status", None) + if value is None: + return None + name = getattr(value, "name", None) or str(value) + return str(name).rsplit(".", 1)[-1].lower() + + +class SolModule: + """``bridge.sol`` — Solana-origin SOL → Aleo over the Hyperlane warp route (spec §6). + + Reads (``balance``, ``quote_transfer_remote``, ``source_status``) work on a read-only + connection; ``transfer_remote(...).send()`` needs a signer. Route metadata is re-validated + from the live registry on every call. + """ + + def __init__(self, bridge: Any, conn: Solana) -> None: + self._bridge = bridge + self.conn = conn + + @property + def client(self) -> Any: + return self.conn.client + + @property + def registry(self) -> Any: + return self._bridge.registry + + @property + def environment(self) -> str: + return self._bridge.environment + + def outbound_route(self) -> Route: + """The environment's SOL → Aleo Hyperlane route (RouteNotFoundError on testnet, which has none). + + ``SOLANA_SOL_ASSET_ID``/``ALEO_SOL_ASSET_ID`` are fixed mainnet asset ids (there is no + testnet Solana chain in the registry), so ``find_route`` alone would resolve the mainnet + route regardless of ``self.environment``; this guards that the route's own environment + matches the module's, mirroring how ``EthModule`` scopes its route lookups by environment. + """ + route = self.registry.find_route(SOLANA_SOL_ASSET_ID, ALEO_SOL_ASSET_ID, protocol="hyperlane") + if route.environment != self.environment: + raise RouteNotFoundError(f"No Solana Hyperlane route to Aleo for environment {self.environment!r}") + return route + + def metadata(self, route: Route | None = None) -> sl.SolanaRouteMetadata: + return sl.solana_route_metadata(route or self.outbound_route()) + + # --- reads ------------------------------------------------------------------------------ + + def _pubkey(self, address: str) -> Any: + return _libs().Pubkey.from_string(address) + + def _account_data(self, address: str) -> bytes | None: + value = self.client.get_account_info(self._pubkey(address), commitment=CONFIRMED, encoding="base64").value + if value is None: + return None + data = value.data + if isinstance(data, (list, tuple)): # raw JSON shape: ["", "base64"] + return base64.b64decode(data[0]) + return bytes(data) + + def _balance_of(self, address: str) -> int: + return int(self.client.get_balance(self._pubkey(address), commitment=CONFIRMED).value) + + def balance(self) -> int: + """Lamports held by the connected wallet.""" + address = self.conn.address + if address is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to read the wallet balance") + return self._balance_of(address) + + # --- quote ------------------------------------------------------------------------------ + + def _make_plan(self, route: Route, *, recipient: str, amount_atomic: int, sender: str, decimals: int) -> Plan: + return build_plan(self.registry, route, amount_atomic=amount_atomic, recipient=recipient, sender=sender) + + def _compile_message(self, metadata: sl.SolanaRouteMetadata, *, sender: str, unique_message: str, + recipient32: bytes, amount_atomic: int) -> tuple[Any, str, int]: + """v0 message: [SetComputeUnitLimit(400_000), TransferRemote] with a confirmed blockhash.""" + libs = _libs() + data = sl.build_transfer_remote_instruction_data(metadata.destination_domain, recipient32, amount_atomic) + metas = [libs.AccountMeta(libs.Pubkey.from_string(m.address), is_signer=m.signer, is_writable=m.writable) + for m in sl.account_metas(metadata, sender, unique_message)] + instruction = libs.Instruction(libs.Pubkey.from_string(metadata.warp_program_address), data, metas) + latest = self.client.get_latest_blockhash(commitment=CONFIRMED).value + message = libs.MessageV0.try_compile( + libs.Pubkey.from_string(sender), + [libs.set_compute_unit_limit(sl.COMPUTE_UNIT_LIMIT), instruction], + [], + latest.blockhash, + ) + return message, str(latest.blockhash), int(latest.last_valid_block_height) + + def quote_transfer_remote(self, recipient: str, *, amount: str | None = None, amount_atomic: int | None = None, + sender: str | None = None, plan: Plan | None = None) -> SolanaHyperlaneQuote: + """Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind + ``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required + for the fee estimate; ``plan`` (from ``Bridge.quote``) pins recipient/amount/sender and must match the live + registry version.""" + libs = _libs() + route = self.outbound_route() + metadata = sl.solana_route_metadata(route) + decimals = self.registry.asset(route.source_asset_id).decimals + if plan is not None: + if plan.registry_version != self.registry.version: + raise RegistryVersionMismatchError( + f"plan was prepared against registry {plan.registry_version}; this client runs {self.registry.version} — re-run quote()") + if plan.route_id != route.id: + raise UnsupportedRouteError(f"plan route {plan.route_id} is not the Solana Hyperlane route {route.id}") + recipient, amount_atomic, amount, sender = plan.recipient, plan.amount_atomic, None, plan.sender + amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if amount_atomic <= 0: + raise InvalidAmountError("amount must be positive") + recipient32 = aleo_address_to_bytes32(recipient) + sender = sender or self.conn.address + if sender is None: + raise ConfigurationError("Solana sender is required to quote the transaction fee: configure a signer or pass sender=") + if plan is None: + plan = self._make_plan(route, recipient=recipient, amount_atomic=amount_atomic, sender=sender, decimals=decimals) + + igp_data = self._account_data(metadata.igp_account) + if igp_data is None: + raise BridgeError(f"Solana IGP account does not exist: {metadata.igp_account}") + igp = sl.quote_igp_lamports(igp_data, metadata.destination_domain, metadata.destination_gas_amount) + + unique = libs.Keypair() # disposable: only its pubkey seeds the fee-estimate message + message, _blockhash, _height = self._compile_message( + metadata, sender=sender, unique_message=str(unique.pubkey()), recipient32=recipient32, amount_atomic=amount_atomic) + fee_value = self.client.get_fee_for_message(message, commitment=CONFIRMED).value + if fee_value is None: + raise BridgeError("Solana RPC getFeeForMessage returned no fee (the blockhash is unknown to the node); retry") + fee = int(fee_value) + rent = sum(int(self.client.get_minimum_balance_for_rent_exemption(size).value) + for size in (sl.GAS_PAYMENT_ACCOUNT_DATA_LENGTH, sl.DISPATCHED_MESSAGE_ACCOUNT_DATA_LENGTH, 0)) + total = amount_atomic + igp + fee + rent + fees = ( + Fee("interchain-gas", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(igp, decimals), True), + Fee("network", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(fee, decimals), True), + Fee("rent", SOLANA_CHAIN_ID, route.source_asset_id, format_decimal_amount(rent, decimals), True), + ) + return SolanaHyperlaneQuote( + kind="solana-hyperlane", plan=plan, fees=fees, amount_out=plan.amount, + igp_lamports=igp, network_fee_lamports=fee, rent_lamports=rent, total_lamports=total, + unique_message_address=str(unique.pubkey()), + ) diff --git a/bridge-sdk/tests/fakes/fake_solana.py b/bridge-sdk/tests/fakes/fake_solana.py new file mode 100644 index 00000000..a8d4c95b --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_solana.py @@ -0,0 +1,136 @@ +"""Fake solana-py ``Client`` for SolModule tests. + +Mirrors solana-py 0.40's shapes: every method returns an object with ``.value``; +``get_latest_blockhash().value`` has ``blockhash``/``last_valid_block_height``; +``get_account_info().value`` is ``None`` or has ``.data: bytes``; signature statuses are a +list with ``err``/``confirmation_status``; ``get_transaction().value.transaction.meta.log_messages``. +""" +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +from solders.hash import Hash +from solders.signature import Signature + +from aleo_bridge.registry import DEFAULT_REGISTRY +from tests.fakes.sealevel_fixtures import ( + DISPATCHED_MESSAGE_RENT_LAMPORTS, + FEE_PAYER_RENT_LAMPORTS, + GAS_PAYMENT_RENT_LAMPORTS, + IGP, + NETWORK_FEE_LAMPORTS, + TRANSFER, + WARP_PROGRAM_ADDRESS, + igp_account_data, +) + +# Any 32-byte base58 string is a valid blockhash for compile/sign purposes (veil uses the warp program id). +BLOCKHASH = Hash.from_string(WARP_PROGRAM_ADDRESS) +LAST_VALID_BLOCK_HEIGHT = 100 +RENTS = {141: GAS_PAYMENT_RENT_LAMPORTS, 194: DISPATCHED_MESSAGE_RENT_LAMPORTS, 0: FEE_PAYER_RENT_LAMPORTS} +STUB_SIGNATURE = Signature.from_bytes(bytes([7]) * 64) + + +class _Resp: + def __init__(self, value: Any) -> None: + self.value = value + + +@dataclass +class FakeSignatureStatus: + err: Any = None + confirmation_status: str | None = "confirmed" + + +@dataclass +class _Blockhash: + blockhash: Hash + last_valid_block_height: int + + +@dataclass +class _Account: + data: bytes + + +class FakeSolanaClient: + """``statuses`` is consumed one entry per ``get_signature_statuses`` call (the last entry repeats); + an entry may be ``None`` (unknown signature), a ``FakeSignatureStatus``, or an ``Exception`` to raise.""" + + def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] | None = None, + fee: int = NETWORK_FEE_LAMPORTS, rents: dict[int, int] | None = None, + statuses: list[Any] | None = None, blockhash_valid: Any = True, + logs: list[str] | None = None, no_logs: bool = False, + signature: Signature = STUB_SIGNATURE) -> None: + self.balance = balance + self.accounts = {IGP["address"]: igp_account_data()} if accounts is None else accounts + self.fee = fee + self.rents = RENTS if rents is None else rents + self.statuses = list(statuses) if statuses is not None else [FakeSignatureStatus()] + self.blockhash_valid = blockhash_valid + # logs=None → the recorded mainnet logs; logs=[] → confirmed but no dispatch line; no_logs → transaction not found + self.logs = None if no_logs else (list(TRANSFER["logMessages"]) if logs is None else list(logs)) + self.signature = signature + self.calls: list[str] = [] + self.fee_messages: list[Any] = [] + self.sent: list[bytes] = [] + self.sent_opts: list[Any] = [] + self.status_calls: list[bool] = [] + self.transaction_calls: list[tuple[Any, Any]] = [] + + def get_latest_blockhash(self, commitment=None): + self.calls.append("get_latest_blockhash") + return _Resp(_Blockhash(BLOCKHASH, LAST_VALID_BLOCK_HEIGHT)) + + def get_balance(self, pubkey, commitment=None): + self.calls.append("get_balance") + return _Resp(self.balance) + + def get_account_info(self, pubkey, commitment=None, encoding="base64", data_slice=None): + self.calls.append("get_account_info") + data = self.accounts.get(str(pubkey)) + return _Resp(None if data is None else _Account(data)) + + def get_fee_for_message(self, message, commitment=None): + self.calls.append("get_fee_for_message") + self.fee_messages.append(message) + return _Resp(self.fee) + + def get_minimum_balance_for_rent_exemption(self, usize, commitment=None): + self.calls.append("get_minimum_balance_for_rent_exemption") + return _Resp(self.rents[usize]) + + def send_raw_transaction(self, txn, opts=None): + self.calls.append("send_raw_transaction") + self.sent.append(bytes(txn)) + self.sent_opts.append(opts) + return _Resp(self.signature) + + def get_signature_statuses(self, signatures, search_transaction_history=False): + self.calls.append("get_signature_statuses") + self.status_calls.append(search_transaction_history) + item = self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0] + if isinstance(item, Exception): + raise item + return _Resp([item]) + + def is_blockhash_valid(self, blockhash, commitment=None): + self.calls.append("is_blockhash_valid") + if isinstance(self.blockhash_valid, Exception): + raise self.blockhash_valid + return _Resp(self.blockhash_valid) + + def get_transaction(self, tx_sig, encoding="json", commitment=None, max_supported_transaction_version=None): + self.calls.append("get_transaction") + self.transaction_calls.append((commitment, max_supported_transaction_version)) + if self.logs is None: + return _Resp(None) + meta = SimpleNamespace(log_messages=list(self.logs)) + return _Resp(SimpleNamespace(transaction=SimpleNamespace(meta=meta))) + + +def stub_bridge(environment: str = "mainnet") -> SimpleNamespace: + """What SolModule needs from a Bridge without constructing one: registry + environment.""" + return SimpleNamespace(registry=DEFAULT_REGISTRY, environment=environment) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 831e632b..3cebfdf7 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -7,7 +7,7 @@ from aleo_bridge import Bridge, __main__ as cli from aleo_bridge._calls import AleoCall -from aleo_bridge.errors import ConfigurationError, MissingExtraError +from aleo_bridge.errors import ConfigurationError from aleo_bridge.eth import Ethereum, EthModule from aleo_bridge.freezelist import FreezeList from aleo_bridge.hyperlane import HyperlaneModule @@ -42,7 +42,11 @@ def test_construction_errors(fake_aleo): Bridge(FakeAleo(default_account=False)).aleo_address() -def test_eth_property_wraps_connection_and_sol_property_before_plan_3(fake_aleo): +def test_eth_property_wraps_connection_and_sol_property_now_wraps_a_bare_client(fake_aleo): + """Plan 3 (task 5) landed ``SolModule``, so ``bridge.sol`` on a configured connection now + succeeds instead of degrading to ``MissingExtraError`` (that fallback covered the window + before ``SolModule`` existed; ``sol.py``'s ``try/except ImportError`` around the import is + still exercised by ``test_sol_connection.py``'s no-solders scenarios).""" bridge = Bridge(fake_aleo) with pytest.raises(ConfigurationError, match="ethereum="): bridge.eth @@ -50,8 +54,12 @@ def test_eth_property_wraps_connection_and_sol_property_before_plan_3(fake_aleo) bridge.sol eth_module = Bridge(fake_aleo, ethereum=fake_web3()).eth # plan 2: real Ethereum wraps a bare Web3 assert isinstance(eth_module, EthModule) - with pytest.raises(MissingExtraError, match="aleo-bridge-sdk\\[solana\\]"): - Bridge(fake_aleo, solana=object()).sol + pytest.importorskip("solders") + from aleo_bridge.sol import SolModule + from tests.fakes.fake_solana import FakeSolanaClient + + sol_module = Bridge(fake_aleo, solana=FakeSolanaClient()).sol # plan 3: bare client is wrapped in Solana + assert isinstance(sol_module, SolModule) def test_program_cache_mapping_value_and_call_registration(fake_aleo): diff --git a/bridge-sdk/tests/test_sol_quote.py b/bridge-sdk/tests/test_sol_quote.py new file mode 100644 index 00000000..950461c1 --- /dev/null +++ b/bridge-sdk/tests/test_sol_quote.py @@ -0,0 +1,115 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair + +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import ( + BridgeError, + ConfigurationError, + InvalidAmountError, + InvalidRecipientError, + RegistryVersionMismatchError, + RouteNotFoundError, +) +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import SolanaHyperlaneQuote +from tests.fakes.fake_solana import FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import ( + DISPATCHED_MESSAGE_RENT_LAMPORTS, + EXPECTED_IGP_PAYMENT_LAMPORTS, + FEE_PAYER_RENT_LAMPORTS, + GAS_PAYMENT_RENT_LAMPORTS, + NETWORK_FEE_LAMPORTS, + TRANSFER, + WARP_PROGRAM_ADDRESS, +) + +RECIPIENT = TRANSFER["recipientAleoAddress"] +SENDER = TRANSFER["senderAddress"] +AMOUNT = TRANSFER["amountLamports"] +RENT = GAS_PAYMENT_RENT_LAMPORTS + DISPATCHED_MESSAGE_RENT_LAMPORTS + FEE_PAYER_RENT_LAMPORTS + + +def module(fake=None, *, signer=None, environment="mainnet") -> tuple[SolModule, FakeSolanaClient]: + fake = fake or FakeSolanaClient() + return SolModule(stub_bridge(environment), Solana(client=fake, signer=signer)), fake + + +def test_quote_with_pinned_sender_on_read_only_connection(): + mod, fake = module() + quote = mod.quote_transfer_remote(RECIPIENT, amount="676.2", sender=SENDER) + assert isinstance(quote, SolanaHyperlaneQuote) and quote.kind == "solana-hyperlane" + assert quote.igp_lamports == EXPECTED_IGP_PAYMENT_LAMPORTS + assert quote.network_fee_lamports == NETWORK_FEE_LAMPORTS + assert quote.rent_lamports == RENT == 5_004_240 + assert quote.total_lamports == AMOUNT + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT == 676_207_914_240 + assert quote.plan.route_id == sl.SOLANA_ROUTE_ID + assert quote.plan.sender == SENDER and quote.plan.recipient == RECIPIENT + assert quote.plan.amount == "676.2" and quote.plan.amount_atomic == AMOUNT + assert quote.plan.mint_mode == "public" and [s.id for s in quote.plan.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert quote.amount_out == "676.2" + assert sl.SOLANA_PUBKEY_RE.match(quote.unique_message_address) + assert [(f.kind, f.amount) for f in quote.fees] == [("interchain-gas", "0.0029"), ("network", "0.00001"), ("rent", "0.00500424")] + assert "send_raw_transaction" not in fake.calls and "get_balance" not in fake.calls + + +def test_quote_fee_message_is_a_v0_message_with_compute_budget_and_transfer_remote(): + mod, fake = module(signer=Keypair()) + quote = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1) + assert quote.plan.sender == mod.conn.address + message = fake.fee_messages[0] + assert message.header.num_required_signatures == 2 + assert message.header.num_readonly_signed_accounts == 1 + programs = [str(message.account_keys[ix.program_id_index]) for ix in message.instructions] + assert programs == ["ComputeBudget111111111111111111111111111111", WARP_PROGRAM_ADDRESS] + assert bytes(message.instructions[0].data) == bytes.fromhex("02801a0600") # SetComputeUnitLimit(400_000) + assert len(bytes(message.instructions[1].data)) == 77 + assert str(message.recent_blockhash) == WARP_PROGRAM_ADDRESS + assert fake.calls.count("get_minimum_balance_for_rent_exemption") == 3 + + +def test_quote_uses_amount_atomic_and_rejects_ambiguous_amounts(): + mod, _ = module() + assert mod.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER).plan.amount == "0.000000001" + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, sender=SENDER) + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, amount="1", amount_atomic=1, sender=SENDER) + with pytest.raises(InvalidAmountError): + mod.quote_transfer_remote(RECIPIENT, amount_atomic=0, sender=SENDER) + + +def test_quote_error_paths(): + mod, _ = module() + with pytest.raises(ConfigurationError, match="sender"): + mod.quote_transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(InvalidRecipientError): + mod.quote_transfer_remote("aleo1notanaddress", amount_atomic=1, sender=SENDER) + missing_igp, _ = module(FakeSolanaClient(accounts={})) + with pytest.raises(BridgeError, match="IGP account does not exist"): + missing_igp.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER) + testnet, _ = module(environment="testnet") + with pytest.raises(RouteNotFoundError): + testnet.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER) + + +def test_quote_with_plan_checks_registry_version_and_reuses_the_plan(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + quoted = mod.quote_transfer_remote(RECIPIENT, plan=plan) + assert quoted.plan is plan and quoted.total_lamports == 5 + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT + stale = dataclasses.replace(plan, registry_version="2020-01-01.stale.0") + with pytest.raises(RegistryVersionMismatchError): + mod.quote_transfer_remote(RECIPIENT, plan=stale) + + +def test_balance_reads_the_connected_wallet(): + keypair = Keypair() + mod, fake = module(FakeSolanaClient(balance=42), signer=keypair) + assert mod.balance() == 42 + read_only, _ = module() + with pytest.raises(ConfigurationError, match="read-only"): + read_only.balance() From 9249dce34cf094f05c14a48a7a93a6f0f2cc79bd Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:42:37 -0400 Subject: [PATCH 55/94] =?UTF-8?q?feat(bridge-sdk):=20SolCall=20=E2=80=94?= =?UTF-8?q?=20partial=20sign,=20fee-payer=20signature,=20broadcast,=20chec?= =?UTF-8?q?kpoint=20and=20confirmation=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bridge-sdk/python/aleo_bridge/_calls.py | 42 ++- bridge-sdk/python/aleo_bridge/_sealevel.py | 18 ++ bridge-sdk/python/aleo_bridge/sol.py | 201 ++++++++++++++ bridge-sdk/tests/fakes/fake_solana.py | 6 +- bridge-sdk/tests/test_sol_send.py | 290 +++++++++++++++++++++ 5 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/test_sol_send.py diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index 759214b7..7bd0b3b8 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -333,5 +333,45 @@ def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_second raise BridgeError("EvmCall has no main step") -__all__ = ["AleoCall", "EvmCall", "EvmOutcome", "EvmStep", "extract_tx_id", "is_duplicate_submission", +class SolCall(Generic[R]): + """A prepared Solana-origin call (spec §7). + + ``build()`` re-quotes, compiles the v0 transaction and signs it with the ephemeral + unique-message keypair only — a preview that spends nothing. ``send()`` rebuilds with a + fresh quote, unique key and blockhash, checks the wallet balance, adds the fee-payer + signature through the connection's signer, broadcasts, hands the ``Checkpoint`` built from the + SOURCE_CONFIRMING receipt to ``on_checkpoint`` and then to the bound store — both before the + first confirmation poll — and returns the typed result. A polling timeout is not a failure: + the pending receipt comes back with the signature and blockhash lifetime. + """ + + def __init__(self, module: Any, *, route: Any, recipient: str, amount_atomic: int, plan: Any, + build_result: Callable[[Any], R], store: "CheckpointStore | None" = None) -> None: + self._module = module + self.route = route + self.recipient = recipient + self.amount_atomic = amount_atomic + self.plan = plan + self._build_result = build_result + self._store = store + self.quote: Any = None + self._built: Any = None + + def build(self) -> Any: + """Partially signed ``VersionedTransaction`` (unique-message signer only); sets ``self.quote``.""" + self._built = self._module._build_transaction(route=self.route, recipient=self.recipient, + amount_atomic=self.amount_atomic, plan=self.plan) + self.quote = self._built.quote + return self._built.transaction + + def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, + on_checkpoint: Callable[[Any], None] | None = None) -> R: + self.build() + receipt = self._module._submit(self._built, wait=wait, timeout_seconds=timeout_seconds, + poll_seconds=poll_seconds, on_checkpoint=on_checkpoint, + store=self._store) + return self._build_result(receipt) + + +__all__ = ["AleoCall", "EvmCall", "EvmOutcome", "EvmStep", "SolCall", "extract_tx_id", "is_duplicate_submission", "output_values", "payload_transitions", "root_outputs"] diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 3b880230..21be31a4 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -368,3 +368,21 @@ def rw(address: str) -> SolanaAccountMeta: rw(metadata.native_collateral_pda), # 15 ]) return metas + + +# --- Program logs ------------------------------------------------------------------------------ + +# SEALEVEL_NOTES §5: only the Mailbox dispatch line carries the full id; the IGP and warp-completion +# lines print H256 with Display (truncated "0xffe0…7805") and must never be parsed. +DISPATCHED_MESSAGE_LOG_PATTERN = re.compile(r"Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})") + + +def extract_hyperlane_message_id(logs: "list[str] | None") -> str | None: + """The 32-byte Hyperlane message id from confirmed program logs, or None when absent.""" + if not logs: + return None + for line in logs: + match = DISPATCHED_MESSAGE_LOG_PATTERN.search(line) + if match: + return match.group(1) + return None diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index e9809a70..6d1569ea 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -20,6 +20,7 @@ import requests from . import _sealevel as sl +from ._calls import SolCall from ._plan import build_plan from .encoding import aleo_address_to_bytes32 from .errors import ( @@ -447,6 +448,63 @@ def _confirmation_name(status: Any) -> str | None: return str(name).rsplit(".", 1)[-1].lower() +@dataclass +class SolBuild: + """Everything ``send`` needs after ``build``: the quote, the compiled message, the partially signed + transaction, the unique-message address that seeds the PDAs, and the blockhash lifetime.""" + quote: SolanaHyperlaneQuote + message: Any + transaction: Any + unique_message_address: str + blockhash: str + last_valid_block_height: int + sender: str + destination_domain: int + + +def _signature_status(client: Any, signature: Any) -> str | None: + """'failed' | 'processed' | 'confirmed' | 'finalized' | None (unknown); raises on RPC errors.""" + value = client.get_signature_statuses([signature], search_transaction_history=True).value + status = value[0] if value else None + if status is None: + return None + if getattr(status, "err", None) is not None: + return "failed" + name = _confirmation_name(status) + if name not in (None, "processed", "confirmed", "finalized"): + raise BridgeError(f"Solana RPC getSignatureStatuses returned unsupported confirmation status: {name}") + return name + + +def _poll_for_confirmation(client: Any, signature: str, blockhash: str, timeout_seconds: float, + poll_seconds: float) -> str | None: + """Poll until confirmed/finalized ('confirmed'|'finalized'), the blockhash expires ('expired'), or the + deadline passes (None). A status read that raises is swallowed — the transaction is already broadcast, + so a transient RPC error must not be reported as a transfer failure. An on-chain ``err`` raises.""" + libs = _libs() + sig = libs.Signature.from_string(signature) + hash_ = libs.Hash.from_string(blockhash) + interval = max(float(poll_seconds), 0.1) + deadline = time.monotonic() + max(float(timeout_seconds), 0.0) + while True: + try: + status = _signature_status(client, sig) + except Exception: # noqa: BLE001 — transport/decoding errors are transient here + status = None + if status == "failed": + raise BridgeError(f"Solana Hyperlane transfer failed on-chain: {signature}") + if status in ("confirmed", "finalized"): + return status + try: + if not client.is_blockhash_valid(hash_, commitment=CONFIRMED).value: + return "expired" + except Exception: # noqa: BLE001 + pass # advisory while the signature may still land + if time.monotonic() >= deadline: + return None + time.sleep(interval) + + class SolModule: """``bridge.sol`` — Solana-origin SOL → Aleo over the Hyperlane warp route (spec §6). @@ -585,3 +643,146 @@ def quote_transfer_remote(self, recipient: str, *, amount: str | None = None, am igp_lamports=igp, network_fee_lamports=fee, rent_lamports=rent, total_lamports=total, unique_message_address=str(unique.pubkey()), ) + + # --- write ------------------------------------------------------------------------------ + + def transfer_remote(self, recipient: str, *, amount: str | None = None, amount_atomic: int | None = None, + plan: Plan | None = None) -> SolCall[DispatchReceipt]: + """Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + + Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, + ``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + ``plan`` (from ``Bridge.execute``) must have been prepared for the connected wallet; its + registry version and route id are re-checked against the live registry when the call runs. + """ + route = self.outbound_route() + sl.solana_route_metadata(route) # refuse inactive/malformed routes early + decimals = self.registry.asset(route.source_asset_id).decimals + if plan is not None: + recipient, amount_atomic, amount = plan.recipient, plan.amount_atomic, None + amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if amount_atomic <= 0: + raise InvalidAmountError("amount must be positive") + aleo_address_to_bytes32(recipient) + + def build_result(receipt: Receipt) -> DispatchReceipt: + return DispatchReceipt(transaction_id=receipt.source_tx_id or receipt.id, route_id=route.id, + message_id=receipt.protocol_state.get("messageId"), + amount_atomic=amount_atomic, receipt=receipt) + + return SolCall(self, route=route, recipient=recipient, amount_atomic=amount_atomic, plan=plan, + build_result=build_result, store=getattr(self._bridge, "checkpoints", None)) + + def _build_transaction(self, *, route: Route, recipient: str, amount_atomic: int, plan: Plan | None) -> SolBuild: + libs = _libs() + sender = self.conn.address + if sender is None: + raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to build transactions") + if plan is not None and plan.sender and plan.sender != sender: + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {sender}") + quote = self.quote_transfer_remote(recipient, amount_atomic=amount_atomic, sender=sender, plan=plan) + metadata = sl.solana_route_metadata(route) + unique = libs.Keypair() # fresh per build: seeds the dispatched-message and gas-payment PDAs + message, blockhash, last_valid_block_height = self._compile_message( + metadata, sender=sender, unique_message=str(unique.pubkey()), + recipient32=aleo_address_to_bytes32(quote.plan.recipient), amount_atomic=quote.plan.amount_atomic) + keys = list(message.account_keys) + if keys[0] != libs.Pubkey.from_string(sender): + raise BridgeError("compiled Solana message does not list the sender as fee payer") + signatures = [libs.Signature.default()] * message.header.num_required_signatures + signatures[keys.index(unique.pubkey())] = unique.sign_message(libs.to_bytes_versioned(message)) + transaction = libs.VersionedTransaction.populate(message, signatures) + return SolBuild(quote=replace(quote, unique_message_address=str(unique.pubkey())), message=message, + transaction=transaction, unique_message_address=str(unique.pubkey()), blockhash=blockhash, + last_valid_block_height=last_valid_block_height, sender=sender, + destination_domain=metadata.destination_domain) + + def _source_receipt(self, built: SolBuild, signature: str) -> Receipt: + return Receipt( + id=signature, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=signature, + protocol_state={ + "routeId": built.quote.plan.route_id, + "signature": signature, + "uniqueMessageAddress": built.unique_message_address, + "destinationDomain": built.destination_domain, + "quotedLamports": str(built.quote.total_lamports), + "blockhash": built.blockhash, + "lastValidBlockHeight": str(built.last_valid_block_height), + }, + ) + + def _transaction_logs(self, signature: str) -> list[str] | None: + libs = _libs() + value = self.client.get_transaction(libs.Signature.from_string(signature), encoding="json", + commitment=CONFIRMED, max_supported_transaction_version=0).value + if value is None: + return None + meta = value.transaction.meta + return None if meta is None else meta.log_messages + + def _delivery_pending(self, receipt: Receipt, signature: str) -> Receipt: + message_id = sl.extract_hyperlane_message_id(self._transaction_logs(signature)) + state = dict(receipt.protocol_state) + if message_id: + state["messageId"] = message_id + else: + state["messageIdUnavailable"] = True + return receipt.replace(id=message_id or signature, status=Status.DELIVERY_PENDING, protocol_state=state) + + def _checkpoint(self, plan: Plan, receipt: Receipt, on_checkpoint: "Callable[[Checkpoint], None] | None", + store: "CheckpointStore | None", signature: str) -> None: + """Emit the checkpoint for a just-broadcast *signature* to the caller first, then the store. + + Mirrors ``EvmCall._checkpoint``: the caller's callback runs before the store because the + transaction is already on the wire; a store failure is then fatal and names the signature, + because losing it silently would strand funds. + """ + from .checkpoint import create_checkpoint + + checkpoint = create_checkpoint(plan, receipt, self.registry) + if on_checkpoint is not None: + on_checkpoint(checkpoint) # the caller's own callback: errors are theirs + if store is not None: + try: + store.save(checkpoint) + except Exception as exc: # noqa: BLE001 — any store backend failure + raise BridgeError( + f"Solana transaction {signature} WAS broadcast but its checkpoint {checkpoint.id} could not be " + f"saved ({exc}); record the signature before retrying — resending would double-spend") from exc + + def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_seconds: float, + on_checkpoint: "Callable[[Checkpoint], None] | None" = None, + store: "CheckpointStore | None" = None) -> Receipt: + libs = _libs() + quote = built.quote + balance = self._balance_of(built.sender) + if balance < quote.total_lamports: + raise InsufficientBalanceError( + f"Insufficient Solana balance for this Hyperlane transfer: balance {balance} lamports, " + f"required {quote.total_lamports} lamports (amount {quote.plan.amount_atomic} " + f"+ gas {quote.igp_lamports + quote.network_fee_lamports} + rent {quote.rent_lamports})") + signatures = list(built.transaction.signatures) + payer_index = list(built.message.account_keys).index(libs.Pubkey.from_string(built.sender)) + signatures[payer_index] = self.conn.sign_message(libs.to_bytes_versioned(built.message)) + signed = libs.VersionedTransaction.populate(built.message, signatures) + opts = SendOptions(skip_preflight=False, preflight_commitment=CONFIRMED) + signature = str(self.client.send_raw_transaction(bytes(signed), opts=opts).value) + receipt = self._source_receipt(built, signature) + self._checkpoint(quote.plan, receipt, on_checkpoint, store, signature) + if not wait: + return receipt + try: + outcome = _poll_for_confirmation(self.client, signature, built.blockhash, timeout_seconds, poll_seconds) + if outcome is None: + return receipt + if outcome == "expired": + return receipt.replace(status=Status.EXPIRED, protocol_state={ + **receipt.protocol_state, "blockhashExpired": True, + "sourceError": f"Solana transaction expired before confirmation: {signature}"}) + return self._delivery_pending(receipt, signature) + except BridgeError as exc: + if signature in str(exc): + raise + raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc + except Exception as exc: # noqa: BLE001 — any post-broadcast failure names the signature + raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc diff --git a/bridge-sdk/tests/fakes/fake_solana.py b/bridge-sdk/tests/fakes/fake_solana.py index a8d4c95b..9f6d6c58 100644 --- a/bridge-sdk/tests/fakes/fake_solana.py +++ b/bridge-sdk/tests/fakes/fake_solana.py @@ -131,6 +131,6 @@ def get_transaction(self, tx_sig, encoding="json", commitment=None, max_supporte return _Resp(SimpleNamespace(transaction=SimpleNamespace(meta=meta))) -def stub_bridge(environment: str = "mainnet") -> SimpleNamespace: - """What SolModule needs from a Bridge without constructing one: registry + environment.""" - return SimpleNamespace(registry=DEFAULT_REGISTRY, environment=environment) +def stub_bridge(environment: str = "mainnet", checkpoints: Any = None) -> SimpleNamespace: + """What SolModule needs from a Bridge without constructing one: registry, environment, checkpoint store.""" + return SimpleNamespace(registry=DEFAULT_REGISTRY, environment=environment, checkpoints=checkpoints) diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py new file mode 100644 index 00000000..2fd8b645 --- /dev/null +++ b/bridge-sdk/tests/test_sol_send.py @@ -0,0 +1,290 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair +from solders.message import to_bytes_versioned +from solders.pubkey import Pubkey +from solders.signature import Signature +from solders.transaction import VersionedTransaction + +from aleo_bridge import _sealevel as sl +from aleo_bridge import sol +from aleo_bridge._calls import SolCall +from aleo_bridge.errors import ( + BridgeError, + ConfigurationError, + InsufficientBalanceError, + RegistryVersionMismatchError, + UnsupportedRouteError, +) +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import DispatchReceipt, Status +from tests.fakes.fake_solana import BLOCKHASH, STUB_SIGNATURE, FakeSignatureStatus, FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import EXPECTED_MESSAGE_ID, TRANSFER, WARP_PROGRAM_ADDRESS + +RECIPIENT = TRANSFER["recipientAleoAddress"] +AMOUNT = TRANSFER["amountLamports"] +SIGNATURE = str(STUB_SIGNATURE) +CHECKPOINT_SOURCE_KEYS = {"transactionId", "blockhash", "lastValidBlockHeight"} + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + monkeypatch.setattr(sol.time, "sleep", lambda seconds: None) + + +def module(fake=None, *, signer=None, checkpoints=None): + fake = fake or FakeSolanaClient() + keypair = signer or Keypair() + bridge = stub_bridge(checkpoints=checkpoints) + return SolModule(bridge, Solana(client=fake, signer=keypair)), fake, keypair + + +class ExplodingStore: + """A checkpoint store whose disk is full / read-only.""" + + def __init__(self): + self.attempts = [] + + def save(self, checkpoint): + self.attempts.append(checkpoint) + raise OSError("read-only file system") + + def load(self, checkpoint_id): # pragma: no cover - never reached + return None + + def list(self): # pragma: no cover - never reached + return [] + + def delete(self, checkpoint_id): # pragma: no cover - never reached + return None + + +def test_extract_message_id_reads_only_the_mailbox_dispatch_line(): + assert sl.extract_hyperlane_message_id(TRANSFER["logMessages"]) == EXPECTED_MESSAGE_ID + truncated = [line for line in TRANSFER["logMessages"] if "Dispatched message" not in line] + assert any("Paid IGP" in line for line in truncated) + assert sl.extract_hyperlane_message_id(truncated) is None + assert sl.extract_hyperlane_message_id(None) is None + assert sl.extract_hyperlane_message_id([]) is None + + +def test_build_returns_a_transaction_signed_only_by_the_unique_message_key(): + mod, fake, keypair = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT) + assert isinstance(call, SolCall) + tx = call.build() + assert isinstance(tx, VersionedTransaction) + message = tx.message + assert message.header.num_required_signatures == 2 + assert message.account_keys[0] == keypair.pubkey() # fee payer first + unique = Pubkey.from_string(call.quote.unique_message_address) + assert message.account_keys[1] == unique + assert tx.signatures[0] == Signature.default() # fee payer unsigned + assert tx.signatures[1] != Signature.default() + assert tx.signatures[1].verify(unique, to_bytes_versioned(message)) + programs = [str(message.account_keys[ix.program_id_index]) for ix in message.instructions] + assert programs == ["ComputeBudget111111111111111111111111111111", WARP_PROGRAM_ADDRESS] + assert fake.sent == [] and call.quote.total_lamports == 676_207_914_240 + + +def test_send_adds_the_fee_payer_signature_confirms_and_extracts_the_message_id(): + mod, fake, keypair = module() + result = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send() + assert isinstance(result, DispatchReceipt) + assert result.transaction_id == SIGNATURE and result.route_id == sl.SOLANA_ROUTE_ID + assert result.message_id == EXPECTED_MESSAGE_ID and result.amount_atomic == AMOUNT + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == EXPECTED_MESSAGE_ID + assert receipt.source_tx_id == SIGNATURE and receipt.protocol == "hyperlane" + assert receipt.protocol_state["messageId"] == EXPECTED_MESSAGE_ID + assert "messageIdUnavailable" not in receipt.protocol_state + assert len(fake.sent) == 1 + tx = VersionedTransaction.from_bytes(fake.sent[0]) + assert all(signature != Signature.default() for signature in tx.signatures) + assert tx.signatures[0].verify(keypair.pubkey(), to_bytes_versioned(tx.message)) + tx.verify_and_hash_message() # raises if any signature is invalid + opts = fake.sent_opts[0] + assert isinstance(opts, sol.SendOptions) + assert opts.skip_preflight is False and opts.preflight_commitment == "confirmed" and opts.skip_confirmation is True + assert fake.status_calls == [True] # searchTransactionHistory + assert fake.transaction_calls == [("confirmed", 0)] # confirmed commitment, v0 + + +def test_send_with_a_non_keypair_signer(): + inner = Keypair() + + class RemoteSigner: + def pubkey(self): + return inner.pubkey() + + def sign_message(self, message: bytes): + return inner.sign_message(message) + + fake = FakeSolanaClient() + mod = SolModule(stub_bridge(), Solana(client=fake, signer=RemoteSigner())) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + tx = VersionedTransaction.from_bytes(fake.sent[0]) + assert tx.signatures[0].verify(inner.pubkey(), to_bytes_versioned(tx.message)) + assert result.receipt.status is Status.DELIVERY_PENDING + + +def test_send_refuses_a_plan_prepared_for_another_sender_before_any_read(): + mod, fake, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=TRANSFER["senderAddress"]).plan + fake.calls.clear() + with pytest.raises(ConfigurationError, match=f"Prepared sender {TRANSFER['senderAddress']} does not match connected account"): + mod.transfer_remote(RECIPIENT, amount_atomic=1, plan=plan).send() + assert fake.calls == [] and fake.sent == [] + + +def test_send_refuses_a_stale_or_foreign_plan(): + mod, fake, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=1).plan + stale = dataclasses.replace(plan, registry_version="2020-01-01.stale.0") + with pytest.raises(RegistryVersionMismatchError): + mod.transfer_remote(RECIPIENT, plan=stale).send() + foreign = dataclasses.replace(plan, route_id="hyperlane:ethereum/eth->aleo/eth") + with pytest.raises(UnsupportedRouteError): + mod.transfer_remote(RECIPIENT, plan=foreign).send() + assert fake.sent == [] + + +def test_send_uses_the_plan_when_it_matches_the_wallet(): + mod, fake, keypair = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=3).plan + assert plan.sender == str(keypair.pubkey()) + result = mod.transfer_remote(RECIPIENT, amount_atomic=3, plan=plan).send() + assert result.receipt.protocol_state["quotedLamports"] == str(3 + 2_900_000 + 10_000 + 5_004_240) + + +def test_send_insufficient_balance_names_the_amount_gas_and_rent_split(): + mod, fake, _ = module(FakeSolanaClient(balance=0)) + with pytest.raises(InsufficientBalanceError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send() + message = str(excinfo.value) + for fragment in ("balance 0 lamports", "required 676207914240 lamports", "amount 676200000000", "gas 2910000", "rent 5004240"): + assert fragment in message + assert fake.sent == [] + + +def test_send_checkpoints_source_confirming_before_the_first_status_read(): + mod, fake, _ = module() + seen = [] + + def on_checkpoint(checkpoint): + fake.calls.append("checkpoint") + seen.append(checkpoint) + + mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send(on_checkpoint=on_checkpoint) + assert len(seen) == 1 + checkpoint = seen[0] + assert checkpoint.id == SIGNATURE + assert checkpoint.route == {"id": sl.SOLANA_ROUTE_ID, "registryVersion": mod.registry.version} + assert set(checkpoint.source) == CHECKPOINT_SOURCE_KEYS + assert checkpoint.source["transactionId"] == SIGNATURE + assert checkpoint.source["blockhash"] == str(BLOCKHASH) and checkpoint.source["lastValidBlockHeight"] == "100" + assert fake.calls.index("send_raw_transaction") < fake.calls.index("checkpoint") < fake.calls.index("get_signature_statuses") + + +def test_bound_store_saves_the_source_checkpoint_after_the_caller_callback(): + saved = [] + + class RecordingStore: + def save(self, checkpoint): + saved.append(checkpoint) + + def load(self, checkpoint_id): + return None + + def list(self): + return [] + + def delete(self, checkpoint_id): + return None + + mod, fake, _ = module(checkpoints=RecordingStore()) + seen = [] + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + assert [cp.id for cp in saved] == [SIGNATURE] == [cp.id for cp in seen] + + +def test_store_failure_after_broadcast_reports_the_signature_and_never_hides_it(): + """The transaction is already on the wire: the caller's callback must have run first, the + error must name the signature and the checkpoint, and no status poll may follow the failure.""" + store = ExplodingStore() + mod, fake, _ = module(checkpoints=store) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + assert SIGNATURE in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [SIGNATURE] # callback ran before the store + assert [cp.id for cp in store.attempts] == [SIGNATURE] + assert len(fake.sent) == 1 # broadcast happened exactly once + assert "get_signature_statuses" not in fake.calls # nothing polled after the failure + + +def test_send_failed_status_raises_naming_the_signature(): + mod, _, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [2, "Custom"]})])) + with pytest.raises(BridgeError, match=SIGNATURE): + mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + + +def test_send_timeout_returns_a_pending_source_confirming_receipt(): + mod, fake, _ = module(FakeSolanaClient(statuses=[None])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == SIGNATURE + assert result.message_id is None and "messageId" not in receipt.protocol_state + assert "get_transaction" not in fake.calls and len(fake.sent) == 1 + + +def test_send_expired_blockhash_returns_expired_without_resubmitting(): + mod, fake, _ = module(FakeSolanaClient(statuses=[None], blockhash_valid=False)) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.EXPIRED + assert receipt.protocol_state["blockhashExpired"] is True + assert SIGNATURE in receipt.protocol_state["sourceError"] + assert len(fake.sent) == 1 + + +def test_send_swallows_transient_status_read_errors(): + fake = FakeSolanaClient(statuses=[RuntimeError("rate limited"), RuntimeError("again"), FakeSignatureStatus()], blockhash_valid=RuntimeError("advisory")) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=5, poll_seconds=0) + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.status_calls) >= 3 + + +def test_send_persistent_status_errors_time_out_to_pending(): + mod, _, _ = module(FakeSolanaClient(statuses=[RuntimeError("down")])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + assert result.receipt.status is Status.SOURCE_CONFIRMING + + +def test_send_without_wait_skips_polling(): + mod, fake, _ = module() + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(wait=False) + assert result.receipt.status is Status.SOURCE_CONFIRMING and "get_signature_statuses" not in fake.calls + + +def test_finalized_counts_as_confirmed_and_missing_log_marks_message_id_unavailable(): + mod, _, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], logs=[])) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == SIGNATURE + assert receipt.protocol_state["messageIdUnavailable"] is True and result.message_id is None + processed_then_confirmed = FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed"), FakeSignatureStatus()]) + mod2, _, _ = module(processed_then_confirmed) + assert mod2.transfer_remote(RECIPIENT, amount_atomic=1).send(poll_seconds=0).receipt.status is Status.DELIVERY_PENDING + + +def test_send_requires_a_signer(): + fake = FakeSolanaClient() + read_only = SolModule(stub_bridge(), Solana(client=fake)) + with pytest.raises(ConfigurationError, match="read-only"): + read_only.transfer_remote(RECIPIENT, amount_atomic=1).build() + assert fake.sent == [] From 3a77bc01484f549eb821ed9b10074de2cb10a041 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:45:27 -0400 Subject: [PATCH 56/94] feat(bridge-sdk): Solana SOURCE_CONFIRMING status refresh with blockhash-lifetime expiry --- bridge-sdk/python/aleo_bridge/sol.py | 39 ++++++++++++ bridge-sdk/tests/test_sol_status.py | 94 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 bridge-sdk/tests/test_sol_status.py diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 6d1569ea..4c81ee1a 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -786,3 +786,42 @@ def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_s raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc except Exception as exc: # noqa: BLE001 — any post-broadcast failure names the signature raise BridgeError(f"Solana Hyperlane transfer {signature} failed after broadcast: {exc}") from exc + + # --- status ----------------------------------------------------------------------------- + + def source_status(self, plan: Plan, receipt: Receipt) -> Receipt: + """One refresh of a SOURCE_CONFIRMING Solana receipt (veil ``getSourceStatus``): unknown → unchanged, or + EXPIRED once the checkpointed blockhash is invalid; processed → unchanged; failed → raises; + confirmed/finalized → DELIVERY_PENDING with the Mailbox message id when the log is available.""" + if receipt.protocol != "hyperlane" or receipt.status is not Status.SOURCE_CONFIRMING or not receipt.source_tx_id: + raise BridgeError("Solana Hyperlane source status requires a source-confirming Hyperlane receipt with a signature") + if receipt.protocol_state.get("routeId") != plan.route_id: + raise BridgeError(f"receipt route {receipt.protocol_state.get('routeId')} does not match plan route {plan.route_id}") + libs = _libs() + signature = receipt.source_tx_id + status = _signature_status(self.client, libs.Signature.from_string(signature)) + if status is None: + blockhash = receipt.protocol_state.get("blockhash") + height = receipt.protocol_state.get("lastValidBlockHeight") + if blockhash is None and height is None: + return receipt + if not isinstance(blockhash, str) or not blockhash or not isinstance(height, str) or not height.isdigit(): + raise CheckpointInvalidError("Solana Hyperlane source receipt has an invalid blockhash lifetime") + try: + hash_ = libs.Hash.from_string(blockhash) + except Exception as exc: + raise CheckpointInvalidError("Solana Hyperlane source receipt has an invalid blockhash lifetime") from exc + try: + valid = bool(self.client.is_blockhash_valid(hash_, commitment=CONFIRMED).value) + except Exception: + return receipt # advisory read; keep waiting + if not valid: + return receipt.replace(status=Status.EXPIRED, protocol_state={ + **receipt.protocol_state, "blockhashExpired": True, + "sourceError": f"Solana transaction expired before confirmation: {signature}"}) + return receipt + if status == "processed": + return receipt + if status == "failed": + raise BridgeError(f"Solana Hyperlane transfer failed on-chain: {signature}") + return self._delivery_pending(receipt, signature) diff --git a/bridge-sdk/tests/test_sol_status.py b/bridge-sdk/tests/test_sol_status.py new file mode 100644 index 00000000..573c2689 --- /dev/null +++ b/bridge-sdk/tests/test_sol_status.py @@ -0,0 +1,94 @@ +import dataclasses + +import pytest + +pytest.importorskip("solders") + +from aleo_bridge.errors import BridgeError, CheckpointInvalidError +from aleo_bridge.sol import SolModule, Solana +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_solana import BLOCKHASH, STUB_SIGNATURE, FakeSignatureStatus, FakeSolanaClient, stub_bridge +from tests.fakes.sealevel_fixtures import EXPECTED_MESSAGE_ID, TRANSFER + +SIGNATURE = str(STUB_SIGNATURE) + + +def setup(fake=None): + fake = fake or FakeSolanaClient() + mod = SolModule(stub_bridge(), Solana(client=fake)) + plan = mod.quote_transfer_remote(TRANSFER["recipientAleoAddress"], amount_atomic=1, sender=TRANSFER["senderAddress"]).plan + fake.calls.clear() + return mod, fake, plan + + +def receipt(plan, **state) -> Receipt: + return Receipt(id=SIGNATURE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=SIGNATURE, + protocol_state={"routeId": plan.route_id, **state}) + + +def test_confirmed_signature_advances_to_delivery_pending_with_message_id(): + mod, fake, plan = setup() + out = mod.source_status(plan, receipt(plan)) + assert out.status is Status.DELIVERY_PENDING and out.id == EXPECTED_MESSAGE_ID + assert out.protocol_state["messageId"] == EXPECTED_MESSAGE_ID and out.source_tx_id == SIGNATURE + assert fake.status_calls == [True] and fake.calls == ["get_signature_statuses", "get_transaction"] + + +def test_finalized_without_log_marks_message_id_unavailable(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], no_logs=True)) + out = mod.source_status(plan, receipt(plan)) + assert out.status is Status.DELIVERY_PENDING and out.id == SIGNATURE and out.protocol_state["messageIdUnavailable"] is True + + +def test_processed_and_unknown_without_lifetime_are_unchanged(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed")])) + original = receipt(plan) + assert mod.source_status(plan, original) == original + mod2, fake2, plan2 = setup(FakeSolanaClient(statuses=[None])) + assert mod2.source_status(plan2, receipt(plan2)) == receipt(plan2) + assert "is_blockhash_valid" not in fake2.calls + + +def test_unknown_signature_with_lifetime_checks_the_blockhash(): + lifetime = {"blockhash": str(BLOCKHASH), "lastValidBlockHeight": "100"} + mod, fake, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=True)) + assert mod.source_status(plan, receipt(plan, **lifetime)) == receipt(plan, **lifetime) + assert "is_blockhash_valid" in fake.calls + expired_mod, _, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=False)) + out = expired_mod.source_status(plan, receipt(plan, **lifetime)) + assert out.status is Status.EXPIRED and out.protocol_state["blockhashExpired"] is True + assert SIGNATURE in out.protocol_state["sourceError"] + flaky_mod, _, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=RuntimeError("rpc"))) + assert flaky_mod.source_status(plan, receipt(plan, **lifetime)) == receipt(plan, **lifetime) + + +def test_malformed_lifetime_is_a_checkpoint_error(): + mod, _, plan = setup(FakeSolanaClient(statuses=[None])) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash=str(BLOCKHASH))) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash=str(BLOCKHASH), lastValidBlockHeight="soon")) + with pytest.raises(CheckpointInvalidError, match="blockhash lifetime"): + mod.source_status(plan, receipt(plan, blockhash="not-base58-0OIl", lastValidBlockHeight="100")) + + +def test_failed_signature_raises_and_status_read_errors_propagate(): + mod, _, plan = setup(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [1, "Custom"]})])) + with pytest.raises(BridgeError, match=SIGNATURE): + mod.source_status(plan, receipt(plan)) + down, _, plan = setup(FakeSolanaClient(statuses=[RuntimeError("rpc down")])) + with pytest.raises(RuntimeError): + down.source_status(plan, receipt(plan)) + + +def test_guards(): + mod, _, plan = setup() + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), status=Status.DELIVERY_PENDING)) + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), source_tx_id=None)) + with pytest.raises(BridgeError, match="source-confirming"): + mod.source_status(plan, dataclasses.replace(receipt(plan), protocol="xreserve")) + with pytest.raises(BridgeError, match="does not match"): + mod.source_status(plan, Receipt(id=SIGNATURE, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=SIGNATURE, protocol_state={"routeId": "hyperlane:ethereum/eth->aleo/eth"})) From 448351e4f6b3919c75512d34eec7ad5f582697cb Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:52:44 -0400 Subject: [PATCH 57/94] feat(bridge-sdk): wire the Solana connection into Bridge (sol property, from_env, status) - Bridge.__init__ eagerly wraps a bare solana-py client into Solana(client=...) and builds a SolModule up front, so bridge.solana is always Solana | None and bridge.sol never lazily imports solders. - solana_from_env() now delegates to Solana.from_env(), picking up the BRIDGE_SOLANA_PRIVATE_KEY/BRIDGE_LIVE_SOLANA_RPC_URL aliases it already supports. - status() appends a solana ChainStatus (address, can_sign, solana/sol balance) when configured. - Export SolModule, SolCall and DEFAULT_SOLANA_RPC_URL from the package alongside Solana. - sol.py: _AsyncClientAdapter.close() stops its private event-loop thread (idempotent); Solana.close() duck-types the wrapped client's close() and Solana is now a context manager. - Fix two from_env/from_profile tests that didn't clear the Solana env aliases, so the dev shell's BRIDGE_SOLANA_PRIVATE_KEY no longer leaks into their bridge.solana is None assertions. --- bridge-sdk/python/aleo_bridge/__init__.py | 6 +- bridge-sdk/python/aleo_bridge/client.py | 45 ++++++------ bridge-sdk/python/aleo_bridge/sol.py | 24 +++++++ bridge-sdk/tests/test_client.py | 9 ++- bridge-sdk/tests/test_sol_bridge.py | 87 +++++++++++++++++++++++ bridge-sdk/tests/test_sol_connection.py | 35 +++++++++ 6 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 bridge-sdk/tests/test_sol_bridge.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 5fcc3472..0133354a 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -21,7 +21,7 @@ MintReceipt, Plan, PreparedTx, PrivacyReceipt, Progress, Quote, Receipt, SolanaHyperlaneQuote, Status, Step, to_progress, ) -from ._calls import AleoCall, EvmCall # noqa: E402 +from ._calls import AleoCall, EvmCall, SolCall # noqa: E402 from .checkpoint import Checkpoint, CheckpointStore, FileCheckpointStore, create_checkpoint # noqa: E402 from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 @@ -30,7 +30,7 @@ from .hyperlane import HyperlaneModule # noqa: E402 from .privacy import PrivacyModule # noqa: E402 from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 -from .sol import Solana # noqa: E402 +from .sol import DEFAULT_SOLANA_RPC_URL, Solana, SolModule # noqa: E402 from .xreserve import XReserveModule # noqa: E402 __all__ = [ @@ -48,5 +48,5 @@ "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", - "Solana", + "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", ] diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 14e0611f..af439efc 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -15,13 +15,14 @@ from typing import TYPE_CHECKING, Any, Callable from ._calls import AleoCall -from .errors import ConfigurationError, MissingExtraError +from .errors import ConfigurationError from .eth import Ethereum, EthModule from .freezelist import FreezeList from .hyperlane import HyperlaneModule from .privacy import PrivacyModule from .profile import DEFAULT_ENDPOINT, Profile from .registry import DEFAULT_REGISTRY, Asset, Chain, Registry, validate_registry +from .sol import Solana, SolModule from .types import BridgeStatus, ChainStatus, PrivacyReceipt from .units import format_decimal_amount, parse_decimal_amount from .xreserve import XReserveModule @@ -80,15 +81,9 @@ def _coerce_ethereum(value: Any) -> Ethereum | None: def solana_from_env() -> Any: - """``Solana(SOLANA_RPC_URL, private_key=SOLANA_PRIVATE_KEY)`` or None (RPC optional).""" - key = os.environ.get("SOLANA_PRIVATE_KEY") - if not key: - return None - try: - from .sol import Solana # plan 3 - except ImportError as exc: - raise MissingExtraError("solana", "A Solana connection from SOLANA_PRIVATE_KEY") from exc - return Solana(os.environ.get("SOLANA_RPC_URL"), private_key=key) + """``Solana.from_env()``: SOLANA_PRIVATE_KEY/BRIDGE_SOLANA_PRIVATE_KEY (+ SOLANA_RPC_URL/ + BRIDGE_LIVE_SOLANA_RPC_URL) or None; a key alone signs, a URL alone is read-only, neither → None.""" + return Solana.from_env() def checkpoints_from_env() -> Any: @@ -126,10 +121,12 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") self.checkpoints = checkpoints self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) - self.solana = solana + if solana is not None and not isinstance(solana, Solana): + solana = Solana(client=solana) # bare solana-py Client → read-only connection (spec §3) + self.solana: Solana | None = solana + self._sol: SolModule | None = SolModule(self, solana) if solana is not None else None self.profile: Profile | None = None self._eth: EthModule | None = None - self._sol_module: Any = None self._programs: dict[str, Any] = {} self.hyperlane = HyperlaneModule(self) self.xreserve = XReserveModule(self) @@ -151,17 +148,13 @@ def eth(self) -> EthModule: return self._eth @property - def sol(self) -> Any: - if self.solana is None: - raise ConfigurationError("Solana is not configured: Bridge(aleo, solana=Solana(...)) or set SOLANA_PRIVATE_KEY") - if self._sol_module is None: - try: - from .sol import Solana, SolModule # plan 3 - except ImportError as exc: - raise MissingExtraError("solana", "Solana-origin bridging") from exc - connection = self.solana if isinstance(self.solana, Solana) else Solana(client=self.solana) - self._sol_module = SolModule(self, connection) - return self._sol_module + def sol(self) -> SolModule: + """Solana-origin module (spec §6). Requires a Solana connection.""" + if self._sol is None: + raise ConfigurationError( + "Solana is not configured: pass solana=Solana(rpc_url, private_key=...) or a solana-py Client to Bridge(), " + "or set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) for Bridge.from_env()") + return self._sol # ── identity / registry helpers ── def aleo_chain(self) -> Chain: @@ -256,10 +249,14 @@ def _aleo_chain_status(self) -> ChainStatus: def status(self) -> BridgeStatus: """Read-only re-orientation: addresses and public balances of every registry asset per configured chain. - Plan 3 appends a Solana ChainStatus entry; plan 4 fills ``pending`` from the checkpoint store.""" + Plan 4 fills ``pending`` from the checkpoint store.""" chains = [self._aleo_chain_status()] if self.ethereum is not None: chains.append(self.eth.chain_status()) + if self.solana is not None: + balances = {"solana/sol": self.sol.balance()} if self.solana.address is not None else {} + chains.append(ChainStatus(chain_id="solana", address=self.solana.address, + can_sign=self.solana.can_sign, balances=balances)) pending: list["Progress"] = [] return BridgeStatus(environment=self.environment, registry_version=self.registry.version, chains=chains, pending=pending) diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 4c81ee1a..eccd77fa 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -290,10 +290,21 @@ def __init__(self, client: Any) -> None: self._loop = asyncio.new_event_loop() self._thread = threading.Thread(target=self._loop.run_forever, name="aleo-bridge-solana-rpc", daemon=True) self._thread.start() + self._closed = False def _run(self, coroutine: Any) -> Any: return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() + def close(self, timeout: float = 5.0) -> None: + """Stop the private event-loop thread and close the loop. Idempotent — a second call is a no-op.""" + if self._closed: + return + self._closed = True + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=timeout) + if not self._loop.is_closed(): + self._loop.close() + def __getattr__(self, name: str) -> Any: attribute = getattr(self._client, name) if inspect.iscoroutinefunction(attribute): @@ -438,6 +449,19 @@ def sign_message(self, message: bytes) -> Any: raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to sign") return self._signer.sign_message(bytes(message)) + def close(self) -> None: + """Release the wrapped client's resources (idempotent). A no-op unless the client exposes its own + ``close()`` — e.g. the private event-loop thread behind an adapted async solana-py client.""" + closer = getattr(self._client, "close", None) + if callable(closer): + closer() + + def __enter__(self) -> "Solana": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.close() + def _confirmation_name(status: Any) -> str | None: """Normalise solders' TransactionConfirmationStatus enum (or a plain string) to 'processed'|'confirmed'|'finalized'.""" diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 3cebfdf7..a98d8c90 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -122,7 +122,8 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) for var in ("BRIDGE_PRIVATE_KEY", "ALEO_ENDPOINT", "ALEO_NETWORK", "ALEO_API_KEY", "ALEO_CONSUMER_ID", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", - "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", + "BRIDGE_CHECKPOINT_DIR"): monkeypatch.delenv(var, raising=False) with pytest.raises(ConfigurationError, match="BRIDGE_PRIVATE_KEY"): Bridge.from_env() @@ -148,7 +149,8 @@ def test_from_env_side_chain_variables(monkeypatch, tmp_path): monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *a, **k: FakeAleo(mappings=default_mappings())) monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpTest") for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", - "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + "SOLANA_PRIVATE_KEY", "SOLANA_RPC_URL", "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", + "BRIDGE_CHECKPOINT_DIR"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("EVM_PRIVATE_KEY", "0x" + "11" * 32) with pytest.raises(ConfigurationError, match="both EVM_PRIVATE_KEY and ETHEREUM_RPC_URL"): @@ -195,7 +197,8 @@ def fake_build(endpoint, network, private_key, *, api_key=None, consumer_id=None monkeypatch.setattr("aleo_bridge.client.build_aleo", fake_build) for var in ("BRIDGE_PRIVATE_KEY", "BRIDGE_PRIVATE_KEY_FILE", "EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", - "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): + "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", "SOLANA_PRIVATE_KEY", + "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", "ALEO_API_KEY", "ALEO_CONSUMER_ID"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("ALEO_BRIDGE_HOME", str(tmp_path / "home")) bridge = Bridge.from_profile(network="testnet", endpoint="https://api.provable.com/v2") diff --git a/bridge-sdk/tests/test_sol_bridge.py b/bridge-sdk/tests/test_sol_bridge.py new file mode 100644 index 00000000..c5e3e7e6 --- /dev/null +++ b/bridge-sdk/tests/test_sol_bridge.py @@ -0,0 +1,87 @@ +"""Task 8: wiring the Solana connection into Bridge (sol property, from_env, status).""" +import pytest + +pytest.importorskip("solders") +from solders.keypair import Keypair + +import aleo_bridge +from aleo_bridge import Bridge, Solana +from aleo_bridge._base58 import b58encode +from aleo_bridge.errors import ConfigurationError +from aleo_bridge.sol import SolModule +from aleo_bridge.types import ChainStatus +from tests.conftest import FakeAleo +from tests.fakes.fake_solana import FakeSolanaClient + + +def test_sol_property_raises_when_solana_is_not_configured(): + bridge = Bridge(FakeAleo()) + assert bridge.solana is None + with pytest.raises(ConfigurationError, match="Solana is not configured"): + bridge.sol + + +def test_bare_client_is_wrapped_read_only(): + fake = FakeSolanaClient(balance=5) + bridge = Bridge(FakeAleo(), solana=fake) + assert isinstance(bridge.solana, Solana) and bridge.solana.client is fake + assert bridge.solana.can_sign is False and bridge.solana.address is None + assert isinstance(bridge.sol, SolModule) and bridge.sol is bridge.sol + with pytest.raises(ConfigurationError, match="read-only"): + bridge.sol.balance() + solana_status = [c for c in bridge.status().chains if c.chain_id == "solana"] + assert solana_status == [ChainStatus(chain_id="solana", address=None, can_sign=False, balances={})] + + +def test_configured_connection_reports_sol_balance_in_status(): + keypair = Keypair() + fake = FakeSolanaClient(balance=1_234) + bridge = Bridge(FakeAleo(), solana=Solana(client=fake, signer=keypair)) + assert bridge.solana.address == str(keypair.pubkey()) + solana_status = [c for c in bridge.status().chains if c.chain_id == "solana"][0] + assert solana_status.address == str(keypair.pubkey()) and solana_status.can_sign is True + assert solana_status.balances == {"solana/sol": 1_234} + + +def test_from_env_builds_the_solana_connection(monkeypatch): + key = b58encode(bytes(Keypair())) + seen = {} + + def capture_init(self, aleo, *, ethereum=None, solana=None, environment=None, registry=None, checkpoints=None): + seen["solana"] = solana + + monkeypatch.setattr(Bridge, "__init__", capture_init) + monkeypatch.setattr("aleo_bridge.client.build_aleo", lambda *args, **kwargs: FakeAleo()) + for var in ("EVM_PRIVATE_KEY", "ETHEREUM_RPC_URL", "BRIDGE_EVM_PRIVATE_KEY", "BRIDGE_LIVE_ETHEREUM_RPC_URL", + "BRIDGE_SOLANA_PRIVATE_KEY", "BRIDGE_LIVE_SOLANA_RPC_URL", "BRIDGE_CHECKPOINT_DIR"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH") + monkeypatch.setenv("SOLANA_PRIVATE_KEY", key) + monkeypatch.setenv("SOLANA_RPC_URL", "https://rpc.example") + Bridge.from_env() + assert isinstance(seen["solana"], Solana) and seen["solana"].can_sign and seen["solana"].rpc_url == "https://rpc.example" + override = Solana(client=FakeSolanaClient()) + Bridge.from_env(solana=override) + assert seen["solana"] is override + + +def test_package_exports(): + assert aleo_bridge.Solana is Solana + assert aleo_bridge.SolModule is SolModule + assert aleo_bridge.DEFAULT_SOLANA_RPC_URL == "https://api.mainnet-beta.solana.com" + from aleo_bridge._calls import AleoCall, EvmCall, SolCall + from aleo_bridge.eth import EthModule + from aleo_bridge.freezelist import FreezeList + from aleo_bridge.hyperlane import HyperlaneModule + from aleo_bridge.profile import Profile + from aleo_bridge.xreserve import XReserveModule + + assert aleo_bridge.SolCall is SolCall + # pre-existing exports (plans 1/2/4) must still be exported after this task's __init__.py edit + assert aleo_bridge.EthModule is EthModule + assert aleo_bridge.HyperlaneModule is HyperlaneModule + assert aleo_bridge.XReserveModule is XReserveModule + assert aleo_bridge.AleoCall is AleoCall + assert aleo_bridge.EvmCall is EvmCall + assert aleo_bridge.FreezeList is FreezeList + assert aleo_bridge.Profile is Profile diff --git a/bridge-sdk/tests/test_sol_connection.py b/bridge-sdk/tests/test_sol_connection.py index 49f32525..0e36a1be 100644 --- a/bridge-sdk/tests/test_sol_connection.py +++ b/bridge-sdk/tests/test_sol_connection.py @@ -180,6 +180,41 @@ def test_from_env(): assert read_only is not None and read_only.can_sign is False +def test_async_client_adapter_close_stops_the_thread(): + pytest.importorskip("solders") + + class FakeAsyncClient: + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + adapter = sol._AsyncClientAdapter(FakeAsyncClient()) + assert adapter._thread.is_alive() + adapter.close() + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: no error, no hang + + +def test_solana_context_manager_closes_the_wrapped_adapter(): + pytest.importorskip("solders") + + class FakeAsyncClient: + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + fake = FakeAsyncClient() + with sol.Solana(client=fake) as conn: + adapter = conn.client + assert isinstance(adapter, sol._AsyncClientAdapter) + assert adapter._thread.is_alive() + assert adapter._thread.is_alive() is False + + +def test_close_is_a_noop_for_a_connection_without_a_closeable_client(): + conn = sol.Solana(client=_Reader()) + conn.close() # no close() on the client — must not raise + conn.close() + + def test_from_env_aliases(): pytest.importorskip("solders") from solders.keypair import Keypair From 7a7c2aad80011eb7ab75cb9f1152f28ec3c415ff Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:55:19 -0400 Subject: [PATCH 58/94] =?UTF-8?q?fix(bridge-sdk):=20SolCall=20polling=20?= =?UTF-8?q?=E2=80=94=20EXPIRED=20only=20without=20a=20status;=20log-fetch?= =?UTF-8?q?=20errors=20degrade=20to=20messageIdUnavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bridge-sdk/python/aleo_bridge/_sealevel.py | 3 +- bridge-sdk/python/aleo_bridge/sol.py | 29 +++++++++++++++----- bridge-sdk/tests/fakes/fake_solana.py | 5 +++- bridge-sdk/tests/test_sol_send.py | 32 ++++++++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 21be31a4..1502e179 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -374,7 +374,8 @@ def rw(address: str) -> SolanaAccountMeta: # SEALEVEL_NOTES §5: only the Mailbox dispatch line carries the full id; the IGP and warp-completion # lines print H256 with Display (truncated "0xffe0…7805") and must never be parsed. -DISPATCHED_MESSAGE_LOG_PATTERN = re.compile(r"Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})") +# The trailing lookahead refuses a 65+-hex id outright rather than truncating it to a plausible-looking one. +DISPATCHED_MESSAGE_LOG_PATTERN = re.compile(r"Dispatched message to \d+, ID (0x[0-9a-fA-F]{64})(?![0-9a-fA-F])") def extract_hyperlane_message_id(logs: "list[str] | None") -> str | None: diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index eccd77fa..61afa6aa 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -504,7 +504,11 @@ def _poll_for_confirmation(client: Any, signature: str, blockhash: str, timeout_ poll_seconds: float) -> str | None: """Poll until confirmed/finalized ('confirmed'|'finalized'), the blockhash expires ('expired'), or the deadline passes (None). A status read that raises is swallowed — the transaction is already broadcast, - so a transient RPC error must not be reported as a transfer failure. An on-chain ``err`` raises.""" + so a transient RPC error must not be reported as a transfer failure. An on-chain ``err`` raises. + + The blockhash probe runs only while the signature has NO status at all: a ``processed`` transaction has + already landed, and reporting it 'expired' would invite a resend (the same rule ``source_status`` applies). + ``processed`` therefore keeps polling until it confirms or the deadline passes.""" libs = _libs() sig = libs.Signature.from_string(signature) hash_ = libs.Hash.from_string(blockhash) @@ -519,11 +523,12 @@ def _poll_for_confirmation(client: Any, signature: str, blockhash: str, timeout_ raise BridgeError(f"Solana Hyperlane transfer failed on-chain: {signature}") if status in ("confirmed", "finalized"): return status - try: - if not client.is_blockhash_valid(hash_, commitment=CONFIRMED).value: - return "expired" - except Exception: # noqa: BLE001 - pass # advisory while the signature may still land + if status is None: # no status at all: only then can the blockhash have expired + try: + if not client.is_blockhash_valid(hash_, commitment=CONFIRMED).value: + return "expired" + except Exception: # noqa: BLE001 + pass # advisory while the signature may still land if time.monotonic() >= deadline: return None time.sleep(interval) @@ -745,7 +750,17 @@ def _transaction_logs(self, signature: str) -> list[str] | None: return None if meta is None else meta.log_messages def _delivery_pending(self, receipt: Receipt, signature: str) -> Receipt: - message_id = sl.extract_hyperlane_message_id(self._transaction_logs(signature)) + """Settle a confirmed signature as DELIVERY_PENDING, with the Mailbox message id when readable. + + The logs supply nothing but the message id, and ``messageIdUnavailable`` already covers a missing + dispatch line, so a failing ``getTransaction`` degrades to that fallback instead of turning a + transfer that is already on-chain into a reported failure. + """ + try: + logs = self._transaction_logs(signature) + except Exception: # noqa: BLE001 — RPC or decode failure reading the logs + logs = None + message_id = sl.extract_hyperlane_message_id(logs) state = dict(receipt.protocol_state) if message_id: state["messageId"] = message_id diff --git a/bridge-sdk/tests/fakes/fake_solana.py b/bridge-sdk/tests/fakes/fake_solana.py index 9f6d6c58..4bf2611c 100644 --- a/bridge-sdk/tests/fakes/fake_solana.py +++ b/bridge-sdk/tests/fakes/fake_solana.py @@ -63,7 +63,7 @@ def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] fee: int = NETWORK_FEE_LAMPORTS, rents: dict[int, int] | None = None, statuses: list[Any] | None = None, blockhash_valid: Any = True, logs: list[str] | None = None, no_logs: bool = False, - signature: Signature = STUB_SIGNATURE) -> None: + signature: Signature = STUB_SIGNATURE, get_transaction_error: Exception | None = None) -> None: self.balance = balance self.accounts = {IGP["address"]: igp_account_data()} if accounts is None else accounts self.fee = fee @@ -73,6 +73,7 @@ def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] # logs=None → the recorded mainnet logs; logs=[] → confirmed but no dispatch line; no_logs → transaction not found self.logs = None if no_logs else (list(TRANSFER["logMessages"]) if logs is None else list(logs)) self.signature = signature + self.get_transaction_error = get_transaction_error # raised by get_transaction (RPC/decode failure) self.calls: list[str] = [] self.fee_messages: list[Any] = [] self.sent: list[bytes] = [] @@ -125,6 +126,8 @@ def is_blockhash_valid(self, blockhash, commitment=None): def get_transaction(self, tx_sig, encoding="json", commitment=None, max_supported_transaction_version=None): self.calls.append("get_transaction") self.transaction_calls.append((commitment, max_supported_transaction_version)) + if self.get_transaction_error is not None: + raise self.get_transaction_error if self.logs is None: return _Resp(None) meta = SimpleNamespace(log_messages=list(self.logs)) diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py index 2fd8b645..1642ed70 100644 --- a/bridge-sdk/tests/test_sol_send.py +++ b/bridge-sdk/tests/test_sol_send.py @@ -71,6 +71,14 @@ def test_extract_message_id_reads_only_the_mailbox_dispatch_line(): assert sl.extract_hyperlane_message_id([]) is None +def test_extract_message_id_refuses_an_over_long_hex_id(): + """A 65+-hex id is not a 32-byte message id; truncating it to 64 would report a plausible wrong id.""" + over_long = "Program log: Dispatched message to 1634493807, ID 0x" + "a" * 65 + assert sl.extract_hyperlane_message_id([over_long]) is None + exact = "Program log: Dispatched message to 1634493807, ID 0x" + "a" * 64 + assert sl.extract_hyperlane_message_id([exact]) == "0x" + "a" * 64 + + def test_build_returns_a_transaction_signed_only_by_the_unique_message_key(): mod, fake, keypair = module() call = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT) @@ -252,6 +260,30 @@ def test_send_expired_blockhash_returns_expired_without_resubmitting(): assert len(fake.sent) == 1 +def test_processed_is_never_reported_expired_and_skips_the_blockhash_probe(): + """A processed transaction has landed; calling it EXPIRED would invite a resend (double spend).""" + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed")], blockhash_valid=False) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == SIGNATURE + assert "blockhashExpired" not in receipt.protocol_state + assert "is_blockhash_valid" not in fake.calls + assert len(fake.sent) == 1 + + +def test_log_fetch_failure_after_confirmation_degrades_to_message_id_unavailable(): + """The logs only carry the message id: an RPC failure there must not fail a settled transfer.""" + fake = FakeSolanaClient(get_transaction_error=RuntimeError("rpc")) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + receipt = result.receipt + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == SIGNATURE + assert receipt.protocol_state["messageIdUnavailable"] is True + assert "messageId" not in receipt.protocol_state and result.message_id is None + assert len(fake.sent) == 1 + + def test_send_swallows_transient_status_read_errors(): fake = FakeSolanaClient(statuses=[RuntimeError("rate limited"), RuntimeError("again"), FakeSignatureStatus()], blockhash_valid=RuntimeError("advisory")) mod, _, _ = module(fake) From f34e3a1584252f2d7afc2754ee079cc2e9e0cddf Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 18:59:16 -0400 Subject: [PATCH 59/94] test(bridge-sdk): gated live Solana reads (IGP decode, leg 11 quote) and README Solana section Live run (public mainnet RPC, no key, first attempt, no 429s): leg 11 quote igp=1985000 fee=10000 rent=3652520 total=5647521 lamports (amount_atomic=1, sender=fixture TRANSFER senderAddress). 2 passed. Hermetic: 384 passed, 27 deselected (was 25; +2 new live tests). --- bridge-sdk/README.md | 32 ++++++++++ bridge-sdk/tests/live/test_sol_reads.py | 82 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 bridge-sdk/tests/live/test_sol_reads.py diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 4d42705c..f84d5994 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -67,6 +67,38 @@ A receipt timeout returns a pending receipt, never a failure. Live checks: `BRID for read-only mainnet quotes; `BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR=… SEPOLIA_RPC_URL=… EVM_PRIVATE_KEY=… ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg. +## Solana (SOL → Aleo over Hyperlane) + +Install the extra: `pip install 'aleo-bridge-sdk[solana]'` (solders + solana-py). + + from aleo_bridge import Bridge, Solana + + bridge = Bridge(aleo, solana=Solana(private_key=SOL_KEY)) # default RPC api.mainnet-beta.solana.com + bridge = Bridge(aleo, solana=Solana("https://my-rpc", signer=my_keypair)) # solders Keypair or any pubkey()/sign_message() signer + bridge = Bridge(aleo, solana=my_rpc_client) # bare RPC client (or solana-py AsyncClient) → read-only + + quote = bridge.sol.quote_transfer_remote(aleo_addr, amount="0.01") # amount + IGP + fee + rent, in lamports + call = bridge.sol.transfer_remote(aleo_addr, amount="0.01") + tx = call.build() # VersionedTransaction, unique-message key signed + result = call.send(on_checkpoint=store.save) # fee-payer signature, broadcast, poll to confirmed + result.message_id # Hyperlane message id from the Mailbox log + +`private_key` accepts a base58 secret (Phantom export) or the 64-int JSON array of a solana-cli `id.json`. +`Bridge.from_env()` reads `SOLANA_PRIVATE_KEY` and (optionally) `SOLANA_RPC_URL`. The default transport is the +SDK's own synchronous JSON-RPC client (`aleo_bridge.sol.SolanaRpcClient`, built on `requests`); pass `client=` to +reuse your own — anything with solana-py's read/send methods, or a solana-py `AsyncClient`. Every read uses confirmed +commitment; the transaction sets a 400 000 compute-unit limit; the `SOURCE_CONFIRMING` receipt (signature, +unique-message address, blockhash, last valid block height) is checkpointed before polling, and a polling +timeout returns the pending receipt rather than failing. `on_checkpoint` is optional — passing `checkpoints=store` +to `Bridge(...)` saves every checkpoint automatically, the same channel Ethereum and Aleo calls use. The instruction +encoding and account list are pinned byte-for-byte against a recorded mainnet transfer +(`tests/fixtures/sealevel-transfer-remote.json`). `Solana` supports `close()` and use as a context manager +(`with Solana(...) as solana:`) to release the wrapped client's resources. + +Live read-only checks (no key, no funds): `BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live/test_sol_reads.py -q -s` +decodes the live IGP account and prints a leg-11 quote for a pinned sender; `SOLANA_RPC_URL` overrides the public +default if it rate-limits. The funded round trip runs from `scripts/rehearse.py` (plan 4). + ## Environment `BRIDGE_PRIVATE_KEY` (required by `from_env`), `ALEO_ENDPOINT` (default `https://edge.provable.com/api`), diff --git a/bridge-sdk/tests/live/test_sol_reads.py b/bridge-sdk/tests/live/test_sol_reads.py new file mode 100644 index 00000000..38107bd8 --- /dev/null +++ b/bridge-sdk/tests/live/test_sol_reads.py @@ -0,0 +1,82 @@ +"""Read-only mainnet checks for the Solana side. Gate: BRIDGE_LIVE_READS=1. + +No key, no funds: decodes the live inner IGP account and quotes leg 11 (SOL -> Aleo, 1 lamport) for a +pinned sender. Fund-moving legs 11-12 run from scripts/rehearse.py (plan 4). + +A public RPC's rate limiting (HTTP 429) or a transient 5xx is not a bug in this SDK, so those are +skips, not failures (mirrors tests/live/test_eth_reads.py's ``_run`` helper). +""" +import os +import re + +import pytest + +pytest.importorskip("solders") + +from aleo import Aleo, HTTPProvider + +from aleo_bridge import Bridge, Solana +from aleo_bridge import _sealevel as sl +from aleo_bridge.errors import BridgeError +from tests.fakes.sealevel_fixtures import ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT, TRANSFER, igp_account_data + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(os.environ.get("BRIDGE_LIVE_READS") != "1", reason="set BRIDGE_LIVE_READS=1 to hit mainnet RPCs"), +] + +ALEO_ENDPOINT = os.environ.get("ALEO_ENDPOINT", "https://edge.provable.com/api") +SENDER = os.environ.get("BRIDGE_LIVE_SOLANA_SENDER", TRANSFER["senderAddress"]) +RECIPIENT = os.environ.get("BRIDGE_LIVE_ALEO_RECIPIENT", TRANSFER["recipientAleoAddress"]) + +_HTTP_STATUS_RE = re.compile(r"HTTP status (\d+)") + + +def _run(fn): + """Run *fn*; a 429/5xx (or an unreachable public RPC) is an environment condition, not a test failure.""" + try: + return fn() + except BridgeError as exc: + message = str(exc) + match = _HTTP_STATUS_RE.search(message) + if match and (int(match.group(1)) == 429 or int(match.group(1)) >= 500): + pytest.skip(f"public RPC rate-limited or unavailable ({message})") + if "request failed:" in message: + pytest.skip(f"public RPC unreachable: {message}") + raise + + +@pytest.fixture(scope="module") +def bridge() -> Bridge: + aleo = Aleo(HTTPProvider(ALEO_ENDPOINT, network="mainnet")) + return Bridge(aleo, solana=Solana(os.environ.get("SOLANA_RPC_URL"))) + + +def test_live_igp_account_decodes_with_the_fixture_shape(bridge): + metadata = _run(lambda: bridge.sol.metadata()) + live = _run(lambda: bridge.sol._account_data(metadata.igp_account)) + assert live is not None + account = sl.decode_igp_account(live) + recorded = sl.decode_igp_account(igp_account_data()) + assert account.bump == recorded.bump and account.beneficiary == recorded.beneficiary + assert account.owner == recorded.owner + oracle = account.gas_oracles[ALEO_MAINNET_DOMAIN] + assert oracle.token_decimals == recorded.gas_oracles[ALEO_MAINNET_DOMAIN].token_decimals == 6 + assert oracle.token_exchange_rate > 0 and oracle.gas_price > 0 + lamports = sl.quote_igp_lamports(live, ALEO_MAINNET_DOMAIN, DESTINATION_GAS_AMOUNT) + assert 0 < lamports < 1_000_000_000 # sanity: below 1 SOL; the recorded value was 2_900_000 + + +def test_live_leg_11_quote_for_a_pinned_sender(bridge, record_property): + quote = _run(lambda: bridge.sol.quote_transfer_remote(RECIPIENT, amount_atomic=1, sender=SENDER)) + assert quote.plan.route_id == sl.SOLANA_ROUTE_ID and quote.plan.sender == SENDER + assert quote.igp_lamports > 0 and quote.network_fee_lamports > 0 + client = bridge.solana.client + rents = [int(_run(lambda n=n: client.get_minimum_balance_for_rent_exemption(n)).value) for n in (141, 194, 0)] + assert quote.rent_lamports == sum(rents) + assert quote.total_lamports == 1 + quote.igp_lamports + quote.network_fee_lamports + quote.rent_lamports + record_property("leg_11_quote", { + "igp_lamports": quote.igp_lamports, "network_fee_lamports": quote.network_fee_lamports, + "rent_lamports": quote.rent_lamports, "total_lamports": quote.total_lamports}) + print(f"leg 11 quote: igp={quote.igp_lamports} fee={quote.network_fee_lamports} " + f"rent={quote.rent_lamports} total={quote.total_lamports}") From 38fc1db567c0376e866b0a9e7f6762130bfba373 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:05:19 -0400 Subject: [PATCH 60/94] feat(bridge): pure prepare() planner with veil step lists and route resolution --- bridge-sdk/python/aleo_bridge/_plan.py | 24 ++- bridge-sdk/python/aleo_bridge/lifecycle.py | 126 +++++++++++++ bridge-sdk/tests/test_evm_call.py | 23 +++ bridge-sdk/tests/test_prepare.py | 201 +++++++++++++++++++++ 4 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/lifecycle.py create mode 100644 bridge-sdk/tests/test_prepare.py diff --git a/bridge-sdk/python/aleo_bridge/_plan.py b/bridge-sdk/python/aleo_bridge/_plan.py index 667aaa5e..98b4610a 100644 --- a/bridge-sdk/python/aleo_bridge/_plan.py +++ b/bridge-sdk/python/aleo_bridge/_plan.py @@ -8,7 +8,7 @@ """ from __future__ import annotations -from .errors import BridgeError +from .errors import BridgeError, UnsupportedRouteError from .registry import Asset, Registry, Route from .types import Plan, Step from .units import format_decimal_amount @@ -37,13 +37,25 @@ def build_plan(registry: Registry, route: Route, *, amount_atomic: int, recipien if amount_atomic <= 0: raise BridgeError("amount_atomic must be positive") wallet = _wallet_executor(registry, source) + source_family = registry.chain(source.chain_id).family + destination_family = registry.chain(destination.chain_id).family if route.protocol == "xreserve": - steps = (Step("source-approval", "approve", wallet, False), - Step("source-deposit", "deposit", wallet, True), - Step("deposit-attestation", "wait-attestation", "protocol", False), - Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) + if source_family == "evm" and destination_family == "aleo": + steps = (Step("source-approval", "approve", wallet, False), + Step("source-deposit", "deposit", wallet, True), + Step("deposit-attestation", "wait-attestation", "protocol", False), + Step("destination-mint", "mint", "aleo-wallet" if mint_mode == "private" else "protocol", False)) + elif source_family == "aleo" and destination_family == "evm": + steps = (Step("source-burn", "burn", wallet, True), + Step("withdrawal-attestation", "wait-attestation", "protocol", False), + Step("destination-withdrawal", "withdraw", "protocol", False), + Step("destination-confirmation", "confirm-delivery", "protocol", False)) + else: + raise UnsupportedRouteError(f"Unsupported xReserve route direction: {route.id}") else: - steps = tuple([Step("source-approval", "approve", wallet, False)] if source.kind == "token" else []) + ( + # Aleo ARC-20 tokens need no on-chain approval; only a non-Aleo token source does. + needs_approval = source.kind == "token" and source_family != "aleo" + steps = tuple([Step("source-approval", "approve", wallet, False)] if needs_approval else []) + ( Step("source-dispatch", "dispatch", wallet, True), Step("message-delivery", "wait-delivery", "protocol", False), Step("destination-confirmation", "confirm-delivery", "protocol", False)) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py new file mode 100644 index 00000000..57104372 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -0,0 +1,126 @@ +"""Tier 1 lifecycle verbs — veil's ``quote → execute → wait`` with ``recover`` / +``resume`` / ``complete`` driven by ``Progress.next``. + +Every function takes the registry (and, from later tasks on, the +:class:`~aleo_bridge.client.Bridge`) and touches only its public surface, so the +unit suite can run them against plain fakes without a network. ``Bridge`` binds +thin methods of the same names. + +Order of the file follows the caller journey: planning (``prepare``), pricing +(``quote``), committing funds (``execute``), observing (``get_status``, +``wait``), and recovery (``recover``, ``resume``, ``complete``) — this task +only adds ``prepare`` and the ``resolve_route`` helper every later verb shares. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from ._plan import build_plan +from .errors import ( + CheckpointInvalidError, + ConfigurationError, + InvalidAmountError, + InvalidRecipientError, + RegistryVersionMismatchError, + RouteUnavailableError, +) +from .registry import Asset, Chain, Registry, Route +from .types import Plan +from .units import format_decimal_amount, parse_decimal_amount, resolve_amount + +MINT_MODES = ("public", "record", "private") + + +# ── Route resolution (invariant 1) ──────────────────────────────────────────── + +@dataclass(frozen=True) +class ResolvedRoute: + """The live registry entries behind one plan — re-resolved on every verb.""" + + route: Route + source_asset: Asset + destination_asset: Asset + source_chain: Chain + destination_chain: Chain + + +def resolve_route(registry: Registry, plan: Plan) -> ResolvedRoute: + """Re-resolve *plan* against the live registry; refuse stale or altered plans. + + Raises :class:`RegistryVersionMismatchError` when the plan was built from a + different registry version (re-quote to fix) and + :class:`CheckpointInvalidError` when its route topology no longer matches. + """ + if plan.registry_version != registry.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has " + f"{registry.version}. Re-run quote() to rebuild the plan.") + route = registry.route(plan.route_id) + if (route.protocol != plan.protocol + or route.source_asset_id != plan.source_asset_id + or route.destination_asset_id != plan.destination_asset_id): + raise CheckpointInvalidError( + f"Plan route {plan.route_id} does not match the configured registry " + "(protocol or asset pair differs). Re-run quote().") + source = registry.asset(route.source_asset_id) + destination = registry.asset(route.destination_asset_id) + return ResolvedRoute(route, source, destination, + registry.chain(source.chain_id), registry.chain(destination.chain_id)) + + +def _require_active(route: Route) -> None: + if route.availability != "active": + raise RouteUnavailableError( + f"Route {route.id} is '{route.availability}': it is listed by the registry " + "but cannot move funds until its deployment is reviewed. Pick an active route " + "(bridge.registry.routes()).") + + +# ── prepare ─────────────────────────────────────────────────────────────────── + +def prepare(registry: Registry, *, source, destination, amount=None, amount_atomic=None, + recipient: str, sender: str | None = None, protocol: str | None = None, + mint_mode: str = "public") -> Plan: + """Describe how *amount* of *source* moves to *destination* — pure, no network. + + Resolves the single non-disabled route for the asset pair (``protocol`` + disambiguates), validates the mint mode (non-public only for xReserve into + Aleo), parses the amount with the source decimals AND re-parses it with the + destination decimals so no precision is silently lost, regex-checks the + recipient against the destination chain, then hands off to + :func:`aleo_bridge._plan.build_plan` for the step list — the same builder + ``bridge.eth.*`` / ``bridge.sol.*`` use, so a caller-supplied plan and a + ``prepare()``-built one are always identical for the same route and amount. + Nothing is signed and no chain is contacted; ``quote`` adds live prices on + top of this. + """ + src = registry.asset(source) + dst = registry.asset(destination) + route = registry.find_route(src.id, dst.id, protocol) + dst_chain = registry.chain(dst.chain_id) + + if mint_mode not in MINT_MODES: + raise ConfigurationError(f"mint_mode must be one of {MINT_MODES}, got {mint_mode!r}") + if mint_mode != "public" and dst_chain.family != "aleo": + raise ConfigurationError( + "Aleo mint mode is only valid when the destination chain is Aleo") + if route.protocol != "xreserve" and mint_mode != "public": + raise ConfigurationError( + "record and private mint modes are only supported by xReserve routes") + + atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=src.decimals) + if atomic <= 0: + raise InvalidAmountError("Bridge transfer amount must be greater than zero") + parse_decimal_amount(format_decimal_amount(atomic, src.decimals), dst.decimals) # destination precision check + + if dst.address_regex and not re.fullmatch(dst.address_regex, recipient): + raise InvalidRecipientError( + f"Recipient {recipient!r} does not match the {dst.chain_id} address format " + f"({dst.address_regex})") + + return build_plan(registry, route, amount_atomic=atomic, recipient=recipient, + sender=sender, mint_mode=mint_mode) + + +__all__ = ["MINT_MODES", "ResolvedRoute", "prepare", "resolve_route"] diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index 7126502b..5e8823a8 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -107,6 +107,29 @@ def test_build_plan_uses_a_solana_wallet_for_a_solana_origin(): ] +def test_build_plan_generalizes_xreserve_to_an_aleo_origin_burn(): + """Aleo-origin xReserve withdrawal is a different step shape than the EVM-origin deposit.""" + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("xreserve:aleo/usdcx->ethereum/usdc"), + amount_atomic=10_000_000, recipient=WBTC, sender=None) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-burn", "burn", "aleo-wallet", True), + ("withdrawal-attestation", "wait-attestation", "protocol", False), + ("destination-withdrawal", "withdraw", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + +def test_build_plan_skips_approval_for_aleo_token_sources_over_hyperlane(): + """Aleo ARC-20 tokens (wBTC/USDT mirrored on Aleo) need no approval, unlike their EVM counterparts.""" + plan = build_plan(DEFAULT_REGISTRY, DEFAULT_REGISTRY.route("hyperlane:aleo/wbtc->ethereum/wbtc"), + amount_atomic=1, recipient=WBTC, sender=None) + assert [(s.id, s.kind, s.executor, s.irreversible) for s in plan.steps] == [ + ("source-dispatch", "dispatch", "aleo-wallet", True), + ("message-delivery", "wait-delivery", "protocol", False), + ("destination-confirmation", "confirm-delivery", "protocol", False), + ] + + def test_build_returns_unsigned_dicts_in_order_without_sending(): w3 = fake_web3() call, _ = make_call(w3) diff --git a/bridge-sdk/tests/test_prepare.py b/bridge-sdk/tests/test_prepare.py new file mode 100644 index 00000000..9888f1b3 --- /dev/null +++ b/bridge-sdk/tests/test_prepare.py @@ -0,0 +1,201 @@ +import pytest + +from aleo_bridge._plan import build_plan +from aleo_bridge.errors import (AmbiguousRouteError, ConfigurationError, InvalidAmountError, + InvalidRecipientError, RouteNotFoundError, + RegistryVersionMismatchError, CheckpointInvalidError) +from aleo_bridge.lifecycle import prepare, resolve_route +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import Plan + +ALEO = "aleo1" + "a" * 58 +EVM1 = "0x0000000000000000000000000000000000000001" +SOLANA1 = "11111111111111111111111111111111" + + +def test_resolves_route_from_asset_refs(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO) + assert plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" + assert plan.protocol == "xreserve" and plan.environment == "mainnet" + assert plan.registry_version == DEFAULT_REGISTRY.version + assert (plan.amount, plan.amount_atomic) == ("25", 25_000_000) + assert plan.mint_mode == "public" and plan.sender is None + # tuple refs and case-insensitive keys work too + assert prepare(DEFAULT_REGISTRY, source=("ethereum", "USDC"), destination=("aleo", "usdcx"), + amount="25", recipient=ALEO).route_id == plan.route_id + + +def test_protocol_filter_is_forwarded_and_disambiguates(): + class _Ambiguous: + version = DEFAULT_REGISTRY.version + asset = DEFAULT_REGISTRY.asset + chain = DEFAULT_REGISTRY.chain + seen = [] + + def find_route(self, source, destination, protocol=None): + self.seen.append(protocol) + if protocol is None: + raise AmbiguousRouteError("Multiple bridge routes match; specify protocol") + return DEFAULT_REGISTRY.find_route(source, destination, protocol) + + reg = _Ambiguous() + with pytest.raises(AmbiguousRouteError): + prepare(reg, source="ethereum/usdc", destination="aleo/usdcx", amount="25", recipient=ALEO) + plan = prepare(reg, source="ethereum/usdc", destination="aleo/usdcx", amount="25", + recipient=ALEO, protocol="xreserve") + assert plan.route_id == "xreserve:ethereum/usdc->aleo/usdcx" and reg.seen == [None, "xreserve"] + with pytest.raises(RouteNotFoundError): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, protocol="hyperlane") + + +def test_xreserve_deposit_steps(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25.5", recipient=ALEO) + assert [s.id for s in plan.steps] == ["source-approval", "source-deposit", + "deposit-attestation", "destination-mint"] + assert [s.kind for s in plan.steps] == ["approve", "deposit", "wait-attestation", "mint"] + assert [s.id for s in plan.steps if s.irreversible] == ["source-deposit"] + assert [s.executor for s in plan.steps] == ["evm-wallet", "evm-wallet", "protocol", "protocol"] + + +def test_xreserve_burn_steps(): + plan = prepare(DEFAULT_REGISTRY, source="aleo/usdcx", destination="ethereum/usdc", + amount="10", recipient=EVM1) + assert [s.id for s in plan.steps] == ["source-burn", "withdrawal-attestation", + "destination-withdrawal", "destination-confirmation"] + assert [s.kind for s in plan.steps] == ["burn", "wait-attestation", "withdraw", "confirm-delivery"] + assert [s.executor for s in plan.steps] == ["aleo-wallet", "protocol", "protocol", "protocol"] + assert [s.id for s in plan.steps if s.irreversible] == ["source-burn"] + + +def test_mint_modes(): + record = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="record") + private = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="private") + assert record.mint_mode == "record" and record.steps[-1].executor == "protocol" + assert private.mint_mode == "private" and private.steps[-1].executor == "aleo-wallet" + with pytest.raises(ConfigurationError, match="only valid.*Aleo"): + prepare(DEFAULT_REGISTRY, source="aleo/usdcx", destination="ethereum/usdc", + amount="10", recipient=EVM1, mint_mode="private") + with pytest.raises(ConfigurationError, match="xReserve"): + prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount="0.1", recipient=ALEO, mint_mode="record") + with pytest.raises(ConfigurationError, match="mint_mode"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="secret") + + +def test_hyperlane_steps_approval_only_on_non_aleo_token_sources(): + inbound = prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount="0.1", recipient=ALEO) + assert [s.id for s in inbound.steps] == ["source-approval", "source-dispatch", + "message-delivery", "destination-confirmation"] + assert [s.kind for s in inbound.steps] == ["approve", "dispatch", "wait-delivery", "confirm-delivery"] + assert inbound.steps[0].executor == "evm-wallet" and inbound.steps[-1].executor == "protocol" + assert [s.id for s in inbound.steps if s.irreversible] == ["source-dispatch"] + + native = prepare(DEFAULT_REGISTRY, source="ethereum/eth", destination="aleo/eth", + amount="0.000000000000000001", recipient=ALEO) + assert [s.id for s in native.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert native.amount_atomic == 1 + + outbound = prepare(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=EVM1) + assert [s.id for s in outbound.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert outbound.steps[0].executor == "aleo-wallet" + + sol = prepare(DEFAULT_REGISTRY, source="solana/sol", destination="aleo/sol", + amount="0.000000001", recipient=ALEO, sender="11111111111111111111111111111111") + assert [s.id for s in sol.steps] == ["source-dispatch", "message-delivery", "destination-confirmation"] + assert sol.steps[0].executor == "solana-wallet" and sol.sender == "11111111111111111111111111111111" + + +def test_amount_forms_and_precision(): + by_atomic = prepare(DEFAULT_REGISTRY, source="ethereum/wbtc", destination="aleo/wbtc", + amount_atomic=100_000, recipient=ALEO) + assert (by_atomic.amount, by_atomic.amount_atomic) == ("0.001", 100_000) + with pytest.raises(InvalidAmountError, match="greater than zero"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="0", recipient=ALEO) + with pytest.raises(InvalidAmountError): # 7 fractional digits on a 6-decimal asset + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="0.0000001", recipient=ALEO) + with pytest.raises(InvalidAmountError): # exactly one of amount / amount_atomic + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="1", amount_atomic=1_000_000, recipient=ALEO) + with pytest.raises(InvalidAmountError): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", recipient=ALEO) + + +def test_destination_decimals_are_checked_too(): + class _Reg: + version = DEFAULT_REGISTRY.version + chain = DEFAULT_REGISTRY.chain + find_route = DEFAULT_REGISTRY.find_route + + def asset(self, ref): + asset = DEFAULT_REGISTRY.asset(ref) + if asset.id == "aleo/usdcx": + import dataclasses + return dataclasses.replace(asset, decimals=2) # coarser destination + return asset + + with pytest.raises(InvalidAmountError): + prepare(_Reg(), source="ethereum/usdc", destination="aleo/usdcx", amount="1.001", recipient=ALEO) + assert prepare(_Reg(), source="ethereum/usdc", destination="aleo/usdcx", amount="1.5", + recipient=ALEO).amount_atomic == 1_500_000 + + +def test_recipient_regex(): + with pytest.raises(InvalidRecipientError, match="aleo address format"): + prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="1", recipient="not-an-aleo-address") + with pytest.raises(InvalidRecipientError, match="ethereum address format"): + prepare(DEFAULT_REGISTRY, source="aleo/wbtc", destination="ethereum/wbtc", + amount="0.1", recipient=ALEO) + + +def test_metadata_required_routes_still_plan(): + # veil's prepare only excludes *disabled* routes; quote/execute refuse metadata-required ones. + plan = prepare(DEFAULT_REGISTRY, source="aleo/aleo", destination="ethereum/aleo", + amount="1", recipient=EVM1) + assert plan.route_id == "hyperlane:aleo/aleo->ethereum/aleo" + + +def test_plan_roundtrip_and_resolve_route(): + plan = prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="25", recipient=ALEO, mint_mode="private", sender=EVM1) + again = Plan.from_dict(plan.to_dict()) + assert again == plan + resolved = resolve_route(DEFAULT_REGISTRY, plan) + assert (resolved.route.id, resolved.source_chain.family, resolved.destination_chain.family) == ( + plan.route_id, "evm", "aleo") + import dataclasses + with pytest.raises(RegistryVersionMismatchError): + resolve_route(DEFAULT_REGISTRY, dataclasses.replace(plan, registry_version="old")) + with pytest.raises(CheckpointInvalidError): + resolve_route(DEFAULT_REGISTRY, dataclasses.replace(plan, protocol="hyperlane")) + + +def _recipient_for(chain_family: str) -> str: + return {"aleo": ALEO, "evm": EVM1, "solana": SOLANA1}[chain_family] + + +def test_prepare_equals_build_plan_for_every_active_route(): + # Controller ruling (task-1-controller-notes.md #4): prepare() is only ever a thin + # validating wrapper around the shared _plan.build_plan — for every active route this + # must hold field-by-field, with amount_atomic=1 and a recipient valid for the destination. + for route in DEFAULT_REGISTRY.routes(include_unavailable=True): + if not route.active: + continue + destination = DEFAULT_REGISTRY.asset(route.destination_asset_id) + destination_chain = DEFAULT_REGISTRY.chain(destination.chain_id) + recipient = _recipient_for(destination_chain.family) + prepared = prepare(DEFAULT_REGISTRY, source=route.source_asset_id, + destination=route.destination_asset_id, amount_atomic=1, + recipient=recipient, protocol=route.protocol) + expected = build_plan(DEFAULT_REGISTRY, route, amount_atomic=1, recipient=recipient, sender=None) + assert prepared == expected, route.id From ebee92933dd9af80d73e271c386dc9aa5fadfd57 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:15:24 -0400 Subject: [PATCH 61/94] feat(bridge-sdk): quote() dispatch over protocol modules + duck-typed FakeBridge for lifecycle tests --- bridge-sdk/python/aleo_bridge/lifecycle.py | 102 ++++- bridge-sdk/tests/conftest.py | 17 +- bridge-sdk/tests/fakes/fake_bridge.py | 416 +++++++++++++++++++++ bridge-sdk/tests/test_quote.py | 64 ++++ 4 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/fakes/fake_bridge.py create mode 100644 bridge-sdk/tests/test_quote.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 57104372..b2c700e2 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -14,7 +14,7 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, replace from ._plan import build_plan from .errors import ( @@ -24,9 +24,10 @@ InvalidRecipientError, RegistryVersionMismatchError, RouteUnavailableError, + UnsupportedRouteError, ) from .registry import Asset, Chain, Registry, Route -from .types import Plan +from .types import AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, Quote from .units import format_decimal_amount, parse_decimal_amount, resolve_amount MINT_MODES = ("public", "record", "private") @@ -123,4 +124,99 @@ def prepare(registry: Registry, *, source, destination, amount=None, amount_atom sender=sender, mint_mode=mint_mode) -__all__ = ["MINT_MODES", "ResolvedRoute", "prepare", "resolve_route"] +# ── Connection helpers ──────────────────────────────────────────────────────── + +def _module(bridge, name: str): + """``bridge.eth`` / ``bridge.sol`` or a ConfigurationError that says how to fix it. + + Checks the ``ethereum``/``solana`` connection attribute FIRST: on the real + ``Bridge``, ``eth``/``sol`` are properties that themselves raise + ``ConfigurationError`` when unconfigured, so ``getattr(bridge, name, None)`` + would never see the ``None`` default — it would let that raise propagate + with the property's own (less specific) message instead of this one. + """ + conn = getattr(bridge, "ethereum" if name == "eth" else "solana", None) + if conn is None: + chain, extra, env = (("Ethereum", "evm", "ETHEREUM_RPC_URL / EVM_PRIVATE_KEY") if name == "eth" + else ("Solana", "solana", "SOLANA_RPC_URL / SOLANA_PRIVATE_KEY")) + raise ConfigurationError( + f"This transfer needs a configured {chain} connection: pass " + f"{'ethereum' if name == 'eth' else 'solana'}= to Bridge(...) (pip install " + f"'aleo-bridge-sdk[{extra}]') or set {env} for Bridge.from_env().") + return getattr(bridge, name) + + +def _credits_asset_id(chain: Chain) -> str: + return f"{chain.id}/aleo" + + +# ── quote ───────────────────────────────────────────────────────────────────── + +def quote(bridge, *, source, destination, amount=None, amount_atomic=None, recipient: str, + sender: str | None = None, protocol: str | None = None, mint_mode: str = "public", + secret_nonce: str = "0scalar") -> Quote: + """Price a transfer: ``prepare`` + the source-side live read for the route kind. + + Returns one of ``EvmHyperlaneQuote`` / ``SolanaHyperlaneQuote`` / + ``AleoHyperlaneQuote`` / ``EvmXReserveQuote`` / ``AleoXReserveQuote`` + (``quote.kind``), each carrying the canonical ``plan`` that ``execute`` takes. + Aleo-origin xReserve quotes make no network call (fixed withdrawal fee). + Nothing is signed. + + Dispatches to ``bridge.eth``/``bridge.sol`` with ``plan=`` (never a re-derived + ``asset``/``recipient``/``amount_atomic`` form): those modules re-resolve the + route from the plan and validate it against the live registry themselves. The + quote they return is then re-stamped with this function's own ``plan`` (via + ``replace``) so the plan on the result is always exactly what ``prepare()`` + built, regardless of what the module attached internally. + """ + plan = prepare(bridge.registry, source=source, destination=destination, amount=amount, + amount_atomic=amount_atomic, recipient=recipient, sender=sender, + protocol=protocol, mint_mode=mint_mode) + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + family = resolved.source_chain.family + + if plan.protocol == "hyperlane" and family == "evm": + q = _module(bridge, "eth").quote_transfer_remote(plan=plan) + return replace(q, plan=plan) + if plan.protocol == "hyperlane" and family == "solana": + # Unlike EthModule's quote methods, SolModule.quote_transfer_remote's ``recipient`` has + # no default — it is a required positional argument even though it is fully overwritten + # from ``plan`` on the real module's plan branch. + q = _module(bridge, "sol").quote_transfer_remote(plan.recipient, plan=plan) + return replace(q, plan=plan) + if plan.protocol == "hyperlane" and family == "aleo": + gas = bridge.hyperlane.quote_gas_payment(plan.source_asset_id) + fee = Fee(kind="protocol", chain_id=resolved.source_chain.id, + asset_id=_credits_asset_id(resolved.source_chain), + amount=format_decimal_amount(gas.payment_microcredits, 6), estimated=True) + return AleoHyperlaneQuote(kind="aleo-hyperlane", plan=plan, fees=(fee,), amount_out=plan.amount, + gas_limit=gas.gas_limit, gas_overhead=gas.gas_overhead, + gas_price=gas.gas_price, exchange_rate=gas.exchange_rate, + payment_microcredits=gas.payment_microcredits) + if plan.protocol == "xreserve" and family == "evm": + q = _module(bridge, "eth").quote_deposit_usdc(plan=plan, secret_nonce=secret_nonce) + return replace(q, plan=plan) + if plan.protocol == "xreserve" and family == "aleo": + raw = resolved.route.metadata.get("withdrawalFeeAtomic") + if not isinstance(raw, str) or not raw.isdigit(): + raise RouteUnavailableError(f"xReserve withdrawal fee is missing or invalid: {plan.route_id}") + fee_atomic = int(raw) + decimals = resolved.source_asset.decimals + fee_human = format_decimal_amount(fee_atomic, decimals) + if plan.amount_atomic <= fee_atomic: + raise InvalidAmountError( + f"xReserve burn amount must exceed the {fee_human} {resolved.source_asset.symbol} " + f"withdrawal fee (got {plan.amount})") + return AleoXReserveQuote( + kind="aleo-xreserve", plan=plan, + fees=(Fee(kind="protocol", chain_id=resolved.source_chain.id, asset_id=resolved.source_asset.id, + amount=fee_human, estimated=False),), + amount_out=format_decimal_amount(plan.amount_atomic - fee_atomic, decimals), + withdrawal_fee_atomic=fee_atomic) + raise UnsupportedRouteError( + f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") + + +__all__ = ["MINT_MODES", "ResolvedRoute", "prepare", "quote", "resolve_route"] diff --git a/bridge-sdk/tests/conftest.py b/bridge-sdk/tests/conftest.py index 5008b78c..8161ba03 100644 --- a/bridge-sdk/tests/conftest.py +++ b/bridge-sdk/tests/conftest.py @@ -139,7 +139,11 @@ def __init__(self, aleo: "FakeAleo") -> None: def submit_transaction(self, transaction: Any) -> str: self._aleo.submitted.append(transaction) if self._aleo.duplicate_on_submit: - raise RuntimeError("Transaction 'at1prepared' already exists in the ledger") + from aleo.facade.errors import AleoNetworkError + # Mirrors the real facade: a node's duplicate-broadcast rejection is an AleoNetworkError + # whose message contains "already exists" (matched by is_duplicate_submission), not a + # bare RuntimeError. + raise AleoNetworkError("Transaction 'at1prepared' already exists in the ledger") if isinstance(transaction, str): return str(json.loads(transaction)["id"]) return str(getattr(transaction, "id", "at1built")) @@ -154,6 +158,15 @@ def wait_for_transaction(self, tx_id: str, *, timeout: float = 45.0, poll_interv def get_transaction_object(self, tx_id: str) -> FakeTx: return FakeTx(tx_id, "hyp_warp_token_wbtc_v2.aleo", "transfer_remote", [{"value": "99field"}]) + def get_confirmed_transaction(self, tx_id: str) -> Any: + """Script via ``FakeAleo.confirmed_transactions[tx_id] = ``; else raises TransactionNotFound + (mirrors the real facade's 404 mapping) — additive for lifecycle recovery/status tests.""" + self._aleo.confirmed_transaction_queries.append(tx_id) + if tx_id in self._aleo.confirmed_transactions: + return self._aleo.confirmed_transactions[tx_id] + from aleo.facade.errors import TransactionNotFound + raise TransactionNotFound(tx_id) + class FakeProcess: def __init__(self, aleo: "FakeAleo") -> None: @@ -189,6 +202,8 @@ def __init__(self, mappings: dict | None = None, records: list[dict] | None = No self.fetched: list = [] self.registered: list = [] self.record_queries: list = [] + self.confirmed_transactions: dict[str, Any] = {} # tx id -> confirmed-transaction JSON (script for get_confirmed_transaction) + self.confirmed_transaction_queries: list = [] self.duplicate_on_submit = False self.delegate_returns_id_only = False self.wait_raises = False diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py new file mode 100644 index 00000000..50a7802d --- /dev/null +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -0,0 +1,416 @@ +"""A duck-typed Bridge whose protocol modules record calls and return scripted results. + +Shape fidelity matters: results carry the real ``types`` dataclasses, Aleo calls +expose ``delegate_prepared / prove / submit_prepared`` exactly like ``AleoCall``, +EVM/Solana calls invoke ``on_checkpoint(receipt)`` for every intermediate +submission before returning, and ``eth``/``sol`` are properties that raise +``ConfigurationError`` when the matching ``ethereum``/``solana`` connection is +``None`` — the conventions plans 2/3 implement. + +``FakeBridge.aleo`` is ``tests.conftest.FakeAleo`` (the facade-level fake already +used by plan 1-3 tests) rather than a second, ad hoc Aleo fake: there is exactly +one Aleo fake in this test suite. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, replace +from typing import Any, Callable + +from aleo import AleoNetworkError +from aleo.facade.errors import TransactionNotFound + +from aleo_bridge.errors import AttestationError, ConfigurationError, RegistryVersionMismatchError +from aleo_bridge.registry import DEFAULT_REGISTRY +from aleo_bridge.types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, + DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, GasQuote, + MintReceipt, PreparedTx, PrivacyReceipt, Receipt, + SolanaHyperlaneQuote, Status) + +from tests.conftest import FakeAleo as _ConftestFakeAleo +from tests.conftest import default_mappings + +ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +EVM_ADDRESS = "0x0000000000000000000000000000000000000001" +SOL_ADDRESS = "11111111111111111111111111111111" +OUTBOUND = {"aleo/eth": "ethereum/eth", "aleo/wbtc": "ethereum/wbtc", + "aleo/usdt": "ethereum/usdt", "aleo/sol": "solana/sol"} + + +def serialized_tx(tx_id: str) -> str: + return json.dumps({"type": "execute", "id": tx_id, "fee": {}}) + + +class FakeAleoCall: + def __init__(self, fake: "FakeBridge", program_id: str, function_name: str, inputs: list[str], + tx_id: str, make_result: Callable[[str], Any]) -> None: + self.fake, self.program_id, self.function_name, self.inputs = fake, program_id, function_name, inputs + self.tx_id, self._make = tx_id, make_result + + def simulate(self, account=None): + self.fake.events.append(("simulate", self.function_name)) + return "authorized" + + def prove(self, account=None, **fee) -> PreparedTx: + self.fake.events.append(("prove", self.tx_id)) + return PreparedTx(self.tx_id, serialized_tx(self.tx_id)) + + def delegate_prepared(self, account=None, **fee) -> PreparedTx: + self.fake.events.append(("delegate_prepared", self.tx_id)) + return PreparedTx(self.tx_id, serialized_tx(self.tx_id)) + + def submit_prepared(self, prepared: PreparedTx, *, wait=True, wait_timeout=180.0): + self.fake.events.append(("submit", prepared.transaction_id, wait)) + self.fake.submitted.append(prepared.serialized) + return self._make(prepared.transaction_id) + + def transact(self, account=None, **fee): + self.fake.events.append(("transact", self.tx_id)) + return self._make(self.tx_id) + + def delegate(self, account=None, **kw): + self.fake.events.append(("delegate", self.tx_id)) + return self._make(self.tx_id) + + +class FakeEvmCall: + def __init__(self, fake: "FakeBridge", intermediates: list[Receipt], final: Any) -> None: + self.fake, self.intermediates, self.final = fake, intermediates, final + + def build(self) -> list[dict]: + return [{"to": "0xrouter", "data": "0x", "value": 0}] + + def send(self, *, wait=True, timeout_seconds=120.0, poll_seconds=1.0, on_checkpoint=None): + self.fake.events.append(("evm_send", timeout_seconds, poll_seconds)) + for receipt in self.intermediates: + if on_checkpoint is not None: + on_checkpoint(receipt) + return self.final + + +FakeSolCall = FakeEvmCall + + +@dataclass +class FakeConnection: + address: str | None + can_sign: bool = True + + +class FakeHyperlane: + def __init__(self, fake: "FakeBridge") -> None: + self.fake = fake + self.gas = GasQuote(route_id="hyperlane:aleo/eth->ethereum/eth", gas_limit=44_000, + gas_overhead=159_337, gas_price=1_000_000_000, exchange_rate=402, + payment_microcredits=8_174_147) + self.delivered: dict[str, bool] = {} + + def quote_gas_payment(self, asset) -> GasQuote: + self.fake.calls.append(("hyperlane.quote_gas_payment", asset)) + return replace(self.gas, route_id=f"hyperlane:{asset}->{OUTBOUND[asset]}") + + def transfer_remote(self, asset, recipient, *, amount=None, amount_atomic=None, + as_signer=False, gas_payment_microcredits=None) -> FakeAleoCall: + kw = dict(asset=asset, recipient=recipient, amount=amount, amount_atomic=amount_atomic, + as_signer=as_signer, gas_payment_microcredits=gas_payment_microcredits) + self.fake.calls.append(("hyperlane.transfer_remote", kw)) + route_id = f"hyperlane:{asset}->{OUTBOUND[asset]}" + program = f"hyp_warp_token_{asset.split('/')[1]}_v2.aleo" + fn = "transfer_remote_as_signer" if as_signer else "transfer_remote" + + def make(tx_id: str) -> DispatchReceipt: + receipt = Receipt(id=tx_id, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, + protocol_state={"routeId": route_id, "sourceProgram": program, + "sourceFunction": fn}) + return DispatchReceipt(tx_id, route_id, None, amount_atomic or 0, receipt) + + return FakeAleoCall(self.fake, program, fn, ["<7 literals>"] * 7, self.fake.next_tx_id(), make) + + def is_delivered(self, message_id) -> bool: + key = message_id if isinstance(message_id, str) else "0x" + bytes(message_id).hex() + self.fake.calls.append(("hyperlane.is_delivered", key)) + return self.delivered.get(key, False) + + +class FakeXReserve: + def __init__(self, fake: "FakeBridge") -> None: + self.fake = fake + self.attestations: dict[str, Attestation] = {} # messageHash hex -> Attestation + self.delivered_nonces: set[str] = set() + self.expected_secret_nonce: str | None = None # private_mint raises when it differs + + def _route(self): + env = self.fake.environment + return ("xreserve:aleo/usdcx->ethereum/usdc" if env == "mainnet" + else "xreserve:aleo-testnet/usdcx->sepolia/usdc") + + def burn(self, recipient, *, amount=None, amount_atomic=None, mode="private", + record=None, merkle_proof=None) -> FakeAleoCall: + kw = dict(recipient=recipient, amount=amount, amount_atomic=amount_atomic, mode=mode, + record=record, merkle_proof=merkle_proof) + self.fake.calls.append(("xreserve.burn", kw)) + route_id = self._route() + program = "shielded_usdcx_wrapper.aleo" if mode == "private" else "usdcx_bridge_v2.aleo" + fn = {"private": "private_burn", "public": "burn_public", + "public-as-signer": "burn_public_as_signer"}[mode] + + def make(tx_id: str) -> BurnReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, + protocol_state={"routeId": route_id, "burnMode": mode, + "sourceProgram": program, "sourceFunction": fn}) + return BurnReceipt(tx_id, route_id, mode, amount_atomic or 0, receipt) + + return FakeAleoCall(self.fake, program, fn, [""], self.fake.next_tx_id(), make) + + def private_mint(self, attestation: Attestation, recipient, *, secret_nonce="0scalar", + route=None) -> FakeAleoCall: + self.fake.calls.append(("xreserve.private_mint", {"recipient": recipient, + "secret_nonce": secret_nonce, + "message_hash": "0x" + attestation.message_hash.hex()})) + if self.expected_secret_nonce is not None and secret_nonce != self.expected_secret_nonce: + raise AttestationError("Private mint secret nonce and recipient do not match the attested hook data") + route_id = route.id if route is not None else "xreserve:ethereum/usdc->aleo/usdcx" + + def make(tx_id: str) -> MintReceipt: + receipt = Receipt(id=tx_id, protocol="xreserve", status=Status.DESTINATION_CONFIRMING, + destination_tx_id=tx_id, protocol_state={"routeId": route_id}) + return MintReceipt(tx_id, route_id, receipt) + + return FakeAleoCall(self.fake, "shielded_usdcx_wrapper.aleo", "private_mint", + ["<5 inputs>"], self.fake.next_tx_id(), make) + + def get_attestation(self, message_hash, *, route=None) -> Attestation | None: + key = message_hash if isinstance(message_hash, str) else "0x" + bytes(message_hash).hex() + self.fake.calls.append(("xreserve.get_attestation", key.lower())) + return self.attestations.get(key.lower()) + + def is_delivered(self, nonce, *, route=None) -> bool: + key = nonce if isinstance(nonce, str) else "0x" + bytes(nonce).hex() + self.fake.calls.append(("xreserve.is_delivered", key.lower())) + return key.lower() in self.delivered_nonces + + +class FakeEth: + """Mirrors ``aleo_bridge.eth.EthModule``'s public surface for lifecycle tests. + + ``quote_transfer_remote``/``quote_deposit_usdc`` accept ``plan=`` exactly like the real + module: mutually exclusive with ``asset=``/``route=``/``sender=`` (a ``ValueError`` otherwise), + checked against ``DEFAULT_REGISTRY.version``, and the returned quote carries that same ``plan`` + object (``.plan is plan``) — ``lifecycle.quote`` is what canonicalizes the plan on the result, so + the fake does not need to rebuild one the way the real module does. + """ + + def __init__(self, fake: "FakeBridge", address: str) -> None: + self.fake, self.address = fake, address + self.approval_required = False + self.balances: dict[str, int] = {} + self.delivered: dict[str, bool] = {} + self.hook_data = bytes([0]) + b"\x00" * 64 + self.source_status_result: Receipt | None = None + self.recover_result: Receipt | None = None + self.intermediates: list[Receipt] = [] + + def quote_transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_atomic=None, + route=None, sender=None, plan=None): + if plan is not None: + if asset is not None or route is not None or sender is not None: + raise ValueError("Pass plan= or asset=/route=/sender=, not both") + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + self.fake.calls.append(("eth.quote_transfer_remote", {"plan": plan})) + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=plan, fees=(), amount_out=None, + recipient_bytes32=b"\x00" * 32, + native_value_atomic=plan.amount_atomic + 1000, + native_fee_atomic=1000, approval_required=self.approval_required) + self.fake.calls.append(("eth.quote_transfer_remote", dict(asset=asset, recipient=recipient, + amount_atomic=amount_atomic))) + return EvmHyperlaneQuote(kind="evm-hyperlane", plan=None, fees=(), amount_out=None, + recipient_bytes32=b"\x00" * 32, native_value_atomic=(amount_atomic or 0) + 1000, + native_fee_atomic=1000, approval_required=self.approval_required) + + def transfer_remote(self, asset, recipient, *, amount=None, amount_atomic=None) -> FakeEvmCall: + self.fake.calls.append(("eth.transfer_remote", dict(asset=asset, recipient=recipient, + amount_atomic=amount_atomic))) + route_id = f"hyperlane:{asset}->aleo/{asset.split('/')[1]}" + tx = "0x" + "aa" * 32 + receipt = Receipt(id=tx, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx, + protocol_state={"routeId": route_id, "approvalTxIds": [], "sourceSender": self.address, + "amountAtomic": str(amount_atomic or 0)}) + return FakeEvmCall(self.fake, self.intermediates, DispatchReceipt(tx, route_id, None, amount_atomic or 0, receipt)) + + def quote_deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, + secret_nonce="0scalar", sender=None, route=None, plan=None): + if plan is not None: + if route is not None or sender is not None: + raise ValueError("Pass plan= or route=/sender=, not both") + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + self.fake.calls.append(("eth.quote_deposit_usdc", {"plan": plan, "secret_nonce": secret_nonce})) + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), amount_out=None, + hook_data=self.hook_data, remote_recipient_bytes32=b"\x00" * 32, + balance_atomic=10_000_000, + allowance_atomic=0 if self.approval_required else 10_000_000, + approval_required=self.approval_required, max_fee_atomic=100_000) + mint_mode = "public" if mint_mode is None else mint_mode + self.fake.calls.append(("eth.quote_deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, + mint_mode=mint_mode, secret_nonce=secret_nonce))) + return EvmXReserveQuote(kind="evm-xreserve", plan=None, fees=(), amount_out=None, hook_data=self.hook_data, + remote_recipient_bytes32=b"\x00" * 32, balance_atomic=10_000_000, + allowance_atomic=0 if self.approval_required else 10_000_000, + approval_required=self.approval_required, max_fee_atomic=100_000) + + def deposit_usdc(self, recipient, *, amount=None, amount_atomic=None, mint_mode="public", + secret_nonce="0scalar") -> FakeEvmCall: + self.fake.calls.append(("eth.deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, + mint_mode=mint_mode, secret_nonce=secret_nonce))) + route_id = ("xreserve:ethereum/usdc->aleo/usdcx" if self.fake.environment == "mainnet" + else "xreserve:sepolia/usdc->aleo-testnet/usdcx") + tx = "0x" + "bb" * 32 + message_hash = "0x" + "cc" * 32 + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, source_tx_id=tx, + protocol_state={"routeId": route_id, "approvalTxIds": [], "sourceSender": self.address, + "mintMode": mint_mode, "intendedRecipient": recipient, + "hookData": "0x" + self.hook_data.hex(), "nonce": "0x" + "dd" * 32, + "payload": "0x" + "ee" * 305, "messageHash": message_hash, + "bridgeProgram": "usdcx_bridge_v2.aleo"}) + return FakeEvmCall(self.fake, self.intermediates, + DepositReceipt(tx, route_id, message_hash, "0x" + "dd" * 32, receipt)) + + def balance(self, asset) -> int: + self.fake.calls.append(("eth.balance", asset)) + return self.balances.get(asset, 0) + + def is_delivered(self, message_id) -> bool: + key = message_id if isinstance(message_id, str) else "0x" + bytes(message_id).hex() + self.fake.calls.append(("eth.is_delivered", key)) + return self.delivered.get(key, False) + + def source_status(self, plan, receipt) -> Receipt: + self.fake.calls.append(("eth.source_status", receipt.status)) + return self.source_status_result or receipt + + def recover_source(self, plan, checkpoint, *, required=False) -> Receipt: + self.fake.calls.append(("eth.recover_source", checkpoint.to_dict(), required)) + assert self.recover_result is not None, "script FakeEth.recover_result first" + return self.recover_result + + +class FakeSol: + """Mirrors ``aleo_bridge.sol.SolModule``'s public surface for lifecycle tests. + + ``quote_transfer_remote`` mirrors the REAL ``SolModule.quote_transfer_remote`` signature + exactly: ``recipient`` is required positionally (no default, unlike ``EthModule``'s quote + methods) and, when ``plan=`` is given, the real module silently overwrites + recipient/amount/amount_atomic/sender from the plan rather than raising ``ValueError`` on a + conflict — there is no ``asset=``/``route=`` kwarg to conflict with in the first place. This + fake matches that real behavior rather than the more Eth-like ``ValueError`` ruling. + """ + + def __init__(self, fake: "FakeBridge", address: str) -> None: + self.fake, self.address = fake, address + self.balance_lamports = 0 + self.source_status_result: Receipt | None = None + self.intermediates: list[Receipt] = [] + + def quote_transfer_remote(self, recipient, *, amount=None, amount_atomic=None, sender=None, plan=None): + if plan is not None: + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + self.fake.calls.append(("sol.quote_transfer_remote", {"plan": plan})) + return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=plan, fees=(), amount_out=None, + igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, + total_lamports=plan.amount_atomic + 7_914_240, + unique_message_address="uniq1111111111111111111111111111111111111111") + self.fake.calls.append(("sol.quote_transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) + return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=None, fees=(), amount_out=None, + igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, + total_lamports=(amount_atomic or 0) + 7_914_240, + unique_message_address="uniq1111111111111111111111111111111111111111") + + def transfer_remote(self, recipient, *, amount=None, amount_atomic=None) -> FakeSolCall: + self.fake.calls.append(("sol.transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) + route_id = "hyperlane:solana/sol->aleo/sol" + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=sig, + protocol_state={"routeId": route_id, "signature": sig, "blockhash": "recent", + "lastValidBlockHeight": "123456789"}) + return FakeSolCall(self.fake, self.intermediates, DispatchReceipt(sig, route_id, None, amount_atomic or 0, receipt)) + + def balance(self) -> int: + self.fake.calls.append(("sol.balance",)) + return self.balance_lamports + + def source_status(self, plan, receipt) -> Receipt: + self.fake.calls.append(("sol.source_status", receipt.status)) + return self.source_status_result or receipt + + +class FakeBridge: + """Duck-typed stand-in for ``aleo_bridge.client.Bridge`` (no network, no extras).""" + + def __init__(self, *, environment="mainnet", ethereum=True, solana=False, checkpoints=None) -> None: + self.registry = DEFAULT_REGISTRY + self.environment = self.network = environment + self.checkpoints = checkpoints + self.events: list[tuple] = [] # ordered side effects (prove/submit/checkpoint...) + self.calls: list[tuple] = [] # module method calls with kwargs + self.submitted: list[str] = [] + self._tx = 0 + self.aleo = _ConftestFakeAleo(mappings=default_mappings(), network_name=environment) + self.hyperlane = FakeHyperlane(self) + self.xreserve = FakeXReserve(self) + self.ethereum = FakeConnection(EVM_ADDRESS) if ethereum else None + self._eth = FakeEth(self, EVM_ADDRESS) if ethereum else None + self.solana = FakeConnection(SOL_ADDRESS) if solana else None + self._sol = FakeSol(self, SOL_ADDRESS) if solana else None + self.public_balances: dict[str, int] = {} + + @property + def eth(self) -> FakeEth: + """Mirrors the real ``Bridge.eth`` property: ``ConfigurationError`` when ``ethereum`` is None.""" + if self._eth is None: + raise ConfigurationError("Pass ethereum=Ethereum(...) to Bridge(...) or set ETHEREUM_RPC_URL") + return self._eth + + @property + def sol(self) -> FakeSol: + """Mirrors the real ``Bridge.sol`` property: ``ConfigurationError`` when ``solana`` is None.""" + if self._sol is None: + raise ConfigurationError( + "Solana is not configured: pass solana=Solana(rpc_url, private_key=...) or a solana-py Client to Bridge(), " + "or set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) for Bridge.from_env()") + return self._sol + + def next_tx_id(self) -> str: + self._tx += 1 + return f"at1fake{self._tx}" + + def aleo_address(self) -> str: + return ALEO_RECIPIENT + + # Plan-1 surface the agent tools touch + def shield(self, asset, *, amount=None, amount_atomic=None, recipient=None) -> FakeAleoCall: + self.calls.append(("shield", dict(asset=asset, amount=amount, amount_atomic=amount_atomic))) + return FakeAleoCall(self, "arc20_eth.aleo", "shield", [f"{amount_atomic}u128"], self.next_tx_id(), + lambda tx: PrivacyReceipt(tx, asset, str(amount or amount_atomic), amount_atomic or 0, "shield")) + + def unshield(self, asset, *, amount=None, amount_atomic=None, record=None, merkle_proof=None, recipient=None): + self.calls.append(("unshield", dict(asset=asset, amount=amount, amount_atomic=amount_atomic))) + return FakeAleoCall(self, "arc20_eth.aleo", "unshield", ["", f"{amount_atomic}u128"], self.next_tx_id(), + lambda tx: PrivacyReceipt(tx, asset, str(amount or amount_atomic), amount_atomic or 0, "unshield")) + + def status(self) -> BridgeStatus: + chains = [ChainStatus("aleo" if self.environment == "mainnet" else "aleo-testnet", ALEO_RECIPIENT, True, + dict(self.public_balances))] + if self.ethereum is not None: + chains.append(ChainStatus("ethereum", self.ethereum.address, True, dict(self.eth.balances))) + if self.solana is not None: + chains.append(ChainStatus("solana", self.solana.address, True, {"solana/sol": self.sol.balance_lamports})) + from aleo_bridge.lifecycle import recover + pending = [recover(self, cp) for cp in self.checkpoints.list()] if self.checkpoints else [] + return BridgeStatus(self.environment, self.registry.version, chains, pending) diff --git a/bridge-sdk/tests/test_quote.py b/bridge-sdk/tests/test_quote.py new file mode 100644 index 00000000..1da052e4 --- /dev/null +++ b/bridge-sdk/tests/test_quote.py @@ -0,0 +1,64 @@ +import pytest + +from aleo_bridge.errors import (ConfigurationError, InvalidAmountError, RouteUnavailableError, + UnsupportedRouteError) +from aleo_bridge.lifecycle import quote +from aleo_bridge.types import AleoHyperlaneQuote, AleoXReserveQuote +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + + +def test_evm_hyperlane_quote_dispatches_to_eth_and_carries_the_canonical_plan(): + b = FakeBridge() + q = quote(b, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT, + sender=EVM_ADDRESS) + assert q.kind == "evm-hyperlane" and q.plan.route_id == "hyperlane:ethereum/wbtc->aleo/wbtc" + assert q.plan.sender == EVM_ADDRESS and q.native_value_atomic == 100_000 + 1000 + assert b.calls == [("eth.quote_transfer_remote", {"plan": q.plan})] + + +def test_evm_xreserve_quote_passes_mint_mode_and_secret_nonce(): + b = FakeBridge() + q = quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT, + mint_mode="private", secret_nonce="7scalar") + assert q.kind == "evm-xreserve" and q.plan.mint_mode == "private" + assert b.calls[-1] == ("eth.quote_deposit_usdc", {"plan": q.plan, "secret_nonce": "7scalar"}) + + +def test_solana_hyperlane_quote(): + b = FakeBridge(solana=True) + q = quote(b, source="solana/sol", destination="aleo/sol", amount="0.000000001", recipient=ALEO_RECIPIENT, + sender=SOL_ADDRESS) + assert q.kind == "solana-hyperlane" and q.total_lamports == 1 + 7_914_240 + assert b.calls == [("sol.quote_transfer_remote", {"plan": q.plan})] + + +def test_aleo_hyperlane_quote_reads_the_igp_only(): + b = FakeBridge(ethereum=False) + q = quote(b, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + assert isinstance(q, AleoHyperlaneQuote) and q.kind == "aleo-hyperlane" + assert q.payment_microcredits == 8_174_147 and q.gas_limit == 44_000 + assert q.amount_out == "0.000000000000000001" + assert [f.kind for f in q.fees] == ["protocol"] and q.fees[0].amount == "8.174147" and q.fees[0].estimated + assert q.fees[0].asset_id == "aleo/aleo" + assert b.calls == [("hyperlane.quote_gas_payment", "aleo/eth")] + + +def test_aleo_xreserve_quote_is_offline_and_deducts_the_withdrawal_fee(): + b = FakeBridge(ethereum=False) + q = quote(b, source="aleo/usdcx", destination="ethereum/usdc", amount="2.000001", recipient=EVM_ADDRESS) + assert isinstance(q, AleoXReserveQuote) and q.kind == "aleo-xreserve" + assert q.amount_out == "0.000001" and q.withdrawal_fee_atomic == 2_000_000 + assert q.fees == (q.fees[0],) and q.fees[0].kind == "protocol" and q.fees[0].estimated is False + assert (q.fees[0].chain_id, q.fees[0].asset_id, q.fees[0].amount) == ("aleo", "aleo/usdcx", "2") + assert b.calls == [] # no network + with pytest.raises(InvalidAmountError, match="exceed the 2 USDCx withdrawal fee"): + quote(b, source="aleo/usdcx", destination="ethereum/usdc", amount="2", recipient=EVM_ADDRESS) + + +def test_missing_connection_and_unavailable_route(): + b = FakeBridge(ethereum=False) + with pytest.raises(ConfigurationError, match="Ethereum connection"): + quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT) + with pytest.raises(RouteUnavailableError, match="metadata-required"): + quote(b, source="aleo/aleo", destination="ethereum/aleo", amount="1", recipient=EVM_ADDRESS) From 02391c719d04f181612269a9c5ba24d136aab501 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:22:37 -0400 Subject: [PATCH 62/94] fix(bridge-sdk): never lose a broadcast id, and verify the node echoes the one we signed A transport failure on send_raw_transaction says nothing about whether the node took the bytes, so both chains now capture the locally derived id (the Solana fee-payer signature, the EVM transaction hash) BEFORE broadcasting and wrap any send failure in a BridgeError that names it. A returned id that differs from the signed one raises rather than checkpointing an id that follows the wrong transaction. The fakes now echo the real id (keccak of the raw EVM tx, the transaction's own Solana signature) instead of an invented one, so the echo check is exercised for real; tests read hashes back with provider.hash_at(n) / fake.sent_signature() and arm the pending/reverted/receipt_delay knobs by send order. --- bridge-sdk/python/aleo_bridge/eth.py | 17 ++++- bridge-sdk/python/aleo_bridge/sol.py | 17 ++++- bridge-sdk/tests/fakes/fake_solana.py | 18 +++++- bridge-sdk/tests/fakes/fake_web3.py | 38 +++++++++-- bridge-sdk/tests/test_eth_connection.py | 42 +++++++++++-- .../tests/test_eth_hyperlane_execute.py | 28 ++++----- bridge-sdk/tests/test_eth_xreserve_execute.py | 26 ++++---- bridge-sdk/tests/test_evm_call.py | 63 ++++++++++++------- bridge-sdk/tests/test_sol_send.py | 62 +++++++++++++----- 9 files changed, 229 insertions(+), 82 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index dd4e7bf7..addbf3d5 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -182,7 +182,22 @@ def send_transaction(self, tx: dict) -> str: tx["maxPriorityFeePerGas"] = tip tx["maxFeePerGas"] = int(base_fee) * 2 + tip signed = self._signer.sign_transaction(tx) - return Web3.to_hex(self._w3.eth.send_raw_transaction(signed.raw_transaction)) + # The hash is fixed by the signature, so it exists before the broadcast. Capture it first: if the + # RPC answer is lost the node may still have accepted the bytes, and a caller who never learns the + # hash cannot tell a failed send from a landed one (and would resend, risking a double spend). + local_hash = Web3.to_hex(signed.hash) + try: + echoed = Web3.to_hex(self._w3.eth.send_raw_transaction(signed.raw_transaction)) + except Exception as exc: # noqa: BLE001 — any transport/JSON-RPC failure loses the response, not the send + raise BridgeError( + f"Ethereum transaction {local_hash} may have been broadcast; the RPC response was lost: {exc}" + " — check bridge.eth.source_status / the explorer before retrying") from exc + if echoed.lower() != local_hash.lower(): + raise BridgeError( + f"Ethereum RPC echoed transaction hash {echoed} for a transaction signed as {local_hash}; " + "refusing to checkpoint or follow the wrong hash — check bridge.eth.source_status / the " + f"explorer for {local_hash} before retrying") + return local_hash def wait_for_receipt(self, tx_hash: str, *, timeout_seconds: float, poll_seconds: float) -> dict | None: """Poll ``wait_for_transaction_receipt``; ``None`` on timeout (a timeout is not a failure).""" diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 61afa6aa..2f3c3dee 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -805,7 +805,22 @@ def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_s signatures[payer_index] = self.conn.sign_message(libs.to_bytes_versioned(built.message)) signed = libs.VersionedTransaction.populate(built.message, signatures) opts = SendOptions(skip_preflight=False, preflight_commitment=CONFIRMED) - signature = str(self.client.send_raw_transaction(bytes(signed), opts=opts).value) + # A Solana transaction's id IS its fee-payer signature, so it is known before the send. Capture it + # first: if the RPC answer is lost the bytes may still have reached the cluster, and a caller who + # never learns the signature cannot tell a failed send from a landed one (and would resend). + signature = str(signed.signatures[0]) + try: + response = self.client.send_raw_transaction(bytes(signed), opts=opts) + except Exception as exc: # noqa: BLE001 — any transport/decoding failure loses the response, not the send + raise BridgeError( + f"Solana transaction {signature} may have been broadcast; the RPC response was lost: {exc}" + " — check bridge.sol.source_status / the explorer before retrying") from exc + echoed = str(response.value) + if echoed != signature: + raise BridgeError( + f"Solana RPC echoed signature {echoed} for a transaction signed as {signature}; refusing to " + "checkpoint or follow the wrong id — check bridge.sol.source_status / the explorer for " + f"{signature} before retrying") receipt = self._source_receipt(built, signature) self._checkpoint(quote.plan, receipt, on_checkpoint, store, signature) if not wait: diff --git a/bridge-sdk/tests/fakes/fake_solana.py b/bridge-sdk/tests/fakes/fake_solana.py index 4bf2611c..74c27990 100644 --- a/bridge-sdk/tests/fakes/fake_solana.py +++ b/bridge-sdk/tests/fakes/fake_solana.py @@ -13,6 +13,7 @@ from solders.hash import Hash from solders.signature import Signature +from solders.transaction import VersionedTransaction from aleo_bridge.registry import DEFAULT_REGISTRY from tests.fakes.sealevel_fixtures import ( @@ -63,7 +64,8 @@ def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] fee: int = NETWORK_FEE_LAMPORTS, rents: dict[int, int] | None = None, statuses: list[Any] | None = None, blockhash_valid: Any = True, logs: list[str] | None = None, no_logs: bool = False, - signature: Signature = STUB_SIGNATURE, get_transaction_error: Exception | None = None) -> None: + signature: Signature | None = None, get_transaction_error: Exception | None = None, + send_error: Exception | None = None) -> None: self.balance = balance self.accounts = {IGP["address"]: igp_account_data()} if accounts is None else accounts self.fee = fee @@ -72,8 +74,11 @@ def __init__(self, *, balance: int = 800_000_000_000, accounts: dict[str, bytes] self.blockhash_valid = blockhash_valid # logs=None → the recorded mainnet logs; logs=[] → confirmed but no dispatch line; no_logs → transaction not found self.logs = None if no_logs else (list(TRANSFER["logMessages"]) if logs is None else list(logs)) + # None → echo the transaction's own fee-payer signature, as a real node does. A Signature here + # makes the node answer with a DIFFERENT id than the one the client signed (the mismatch case). self.signature = signature self.get_transaction_error = get_transaction_error # raised by get_transaction (RPC/decode failure) + self.send_error = send_error # raised by send_raw_transaction (response lost) self.calls: list[str] = [] self.fee_messages: list[Any] = [] self.sent: list[bytes] = [] @@ -105,9 +110,16 @@ def get_minimum_balance_for_rent_exemption(self, usize, commitment=None): def send_raw_transaction(self, txn, opts=None): self.calls.append("send_raw_transaction") - self.sent.append(bytes(txn)) + raw = bytes(txn) + self.sent.append(raw) self.sent_opts.append(opts) - return _Resp(self.signature) + if self.send_error is not None: + raise self.send_error + return _Resp(self.signature if self.signature is not None else VersionedTransaction.from_bytes(raw).signatures[0]) + + def sent_signature(self, index: int = 0) -> str: + """Fee-payer signature of the index-th broadcast transaction — the id the node echoed back.""" + return str(VersionedTransaction.from_bytes(self.sent[index]).signatures[0]) def get_signature_statuses(self, signatures, search_transaction_history=False): self.calls.append("get_signature_statuses") diff --git a/bridge-sdk/tests/fakes/fake_web3.py b/bridge-sdk/tests/fakes/fake_web3.py index c3339b14..5e93b5aa 100644 --- a/bridge-sdk/tests/fakes/fake_web3.py +++ b/bridge-sdk/tests/fakes/fake_web3.py @@ -36,7 +36,13 @@ def tx_hash_for(n: int) -> str: - """Deterministic hash of the n-th (1-based) transaction the fake accepted.""" + """Deterministic hash of the n-th (1-based) transaction the fake accepted through ``eth_sendTransaction``. + + Raw (locally signed) transactions get their REAL hash — ``keccak(raw)`` — because eth.py now + checks the node's echoed hash against the one it computed locally, exactly as a real node would + answer. Use ``provider.hash_at(n)`` to read a hash back after the send, and the ``*_nth`` knobs + to arm ``pending``/``reverted``/``receipt_delay`` for a transaction not yet broadcast. + """ return "0x" + keccak(text=f"fake-tx-{n}").hex() @@ -122,6 +128,13 @@ def __init__(self, *, chain_id: int = 1, eth_balances: dict[str, int] | None = N self.pending: set[str] = set() # hashes whose receipt stays None self.reverted: set[str] = set() # hashes whose receipt has status 0 self.receipt_delay: dict[str, int] = {} # hash -> remaining polls that return None before mined + # A locally signed transaction's hash is only known once it is signed, so these arm the three + # knobs above by 1-based send order instead; _accept translates them the moment it accepts. + self.pending_nth: set[int] = set() + self.reverted_nth: set[int] = set() + self.receipt_delay_nth: dict[int, int] = {} + self.send_errors: dict[int, str] = {} # 1-based send -> JSON-RPC error (the response is lost) + self.echo_hashes: dict[int, str] = {} # 1-based send -> hash to echo INSTEAD of the real one self.receipt_poll_counts: dict[str, int] = {} # hash -> eth_getTransactionReceipt calls seen for it self.receipt_logs: Callable[[dict], list[dict]] = lambda tx: [] # logs for a sent tx's receipt self.history_logs: list[dict] = [] # served by eth_getLogs (filtered by address/from/toBlock) @@ -197,7 +210,11 @@ def make_request(self, method: str, params: Any) -> dict: if method == "eth_call": return self._ok(self._call(params[0])) if method == "eth_sendRawTransaction": - return self._ok(self._accept(_decode_raw(bytes.fromhex(params[0][2:])))) + raw = bytes.fromhex(params[0][2:]) + message = self.send_errors.get(len(self.sent) + 1) + if message is not None: # the node took it, the answer never came back + return {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": message}} + return self._ok(self._accept(_decode_raw(raw), raw=raw)) if method == "eth_sendTransaction": p = params[0] raw_value = p.get("value", 0) @@ -235,10 +252,21 @@ def make_request(self, method: str, params: Any) -> dict: "v": "0x0", "r": "0x0", "s": "0x0"}) raise NotImplementedError(method) - def _accept(self, tx: dict) -> str: + def _accept(self, tx: dict, raw: bytes | None = None) -> str: self.sent.append(tx) - tx["hash"] = tx_hash_for(len(self.sent)) - return tx["hash"] + n = len(self.sent) + tx["hash"] = ("0x" + keccak(raw).hex()) if raw is not None else tx_hash_for(n) + if n in self.pending_nth: + self.pending.add(tx["hash"]) + if n in self.reverted_nth: + self.reverted.add(tx["hash"]) + if n in self.receipt_delay_nth: + self.receipt_delay[tx["hash"]] = self.receipt_delay_nth[n] + return self.echo_hashes.get(n, tx["hash"]) + + def hash_at(self, n: int) -> str: + """Hash of the n-th (1-based) accepted transaction — the real one for a locally signed send.""" + return self.sent[n - 1]["hash"] def _receipt(self, h: str) -> dict | None: if h in self.pending: diff --git a/bridge-sdk/tests/test_eth_connection.py b/bridge-sdk/tests/test_eth_connection.py index 79f911e7..8a8063f1 100644 --- a/bridge-sdk/tests/test_eth_connection.py +++ b/bridge-sdk/tests/test_eth_connection.py @@ -4,7 +4,7 @@ from web3.middleware import SignAndSendRawMiddlewareBuilder from aleo_bridge.errors import ConfigurationError -from tests.fakes.fake_web3 import fake_web3, tx_hash_for +from tests.fakes.fake_web3 import fake_web3 KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) @@ -67,7 +67,7 @@ def test_w3_default_account_uses_callers_middleware(): conn = Ethereum(w3=w3) assert conn.address == ACCT.address and conn.can_sign h = conn.send_transaction({"to": TO, "value": 1, "data": "0x"}) - assert h == tx_hash_for(1) + assert h == w3.provider.hash_at(1) assert "eth_sendRawTransaction" in w3.provider.methods # the caller's middleware signed assert w3.provider.sent[0]["from"] == ACCT.address and w3.provider.sent[0]["value"] == 1 @@ -78,7 +78,7 @@ def test_local_account_path_signs_and_sends_raw(): w3 = fake_web3() conn = Ethereum(w3=w3, private_key=KEY) h = conn.send_transaction({"to": TO, "value": 7, "data": "0x"}) - assert h == tx_hash_for(1) + assert h == w3.provider.hash_at(1) assert "eth_sendRawTransaction" in w3.provider.methods and "eth_sendTransaction" not in w3.provider.methods sent = w3.provider.sent[0] assert sent["from"] == ACCT.address and sent["to"] == Web3.to_checksum_address(TO) and sent["value"] == 7 @@ -102,7 +102,7 @@ def test_legacy_gas_price_path_when_no_base_fee(): w3 = fake_web3(legacy=True) conn = Ethereum(w3=w3, private_key=KEY) h = conn.send_transaction({"to": TO, "value": 3, "data": "0x"}) - assert h == tx_hash_for(1) + assert h == w3.provider.hash_at(1) sent = w3.provider.sent[0] assert sent["gasPrice"] == 10**9 assert "maxFeePerGas" not in sent and "maxPriorityFeePerGas" not in sent @@ -117,6 +117,40 @@ def test_sender_mismatch_is_refused(): conn.send_transaction({"from": TO, "to": TO, "value": 0, "data": "0x"}) +def test_a_lost_send_response_names_the_locally_computed_hash(): + """``eth_sendRawTransaction`` failing tells us nothing about whether the node took the bytes: + the hash is already determined by the signature, so it must reach the caller.""" + from aleo_bridge.errors import BridgeError + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + w3.provider.send_errors[1] = "connection reset by peer" + conn = Ethereum(w3=w3, private_key=KEY) + signed = ACCT.sign_transaction({"to": Web3.to_checksum_address(TO), "value": 5, "data": "0x", "chainId": 1, + "nonce": 0, "gas": 150_000 * 12 // 10, "maxPriorityFeePerGas": 10**8, + "maxFeePerGas": 10**9 * 2 + 10**8}) + with pytest.raises(BridgeError) as exc: + conn.send_transaction({"to": TO, "value": 5, "data": "0x"}) + message = str(exc.value) + assert Web3.to_hex(signed.hash) in message and "may have been broadcast" in message + assert "connection reset by peer" in message + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 # exactly one attempt + + +def test_a_node_hash_that_differs_from_the_signed_one_is_refused(): + from aleo_bridge.errors import BridgeError + from aleo_bridge.eth import Ethereum + + w3 = fake_web3() + other = "0x" + "ab" * 32 + w3.provider.echo_hashes[1] = other + conn = Ethereum(w3=w3, private_key=KEY) + with pytest.raises(BridgeError) as exc: + conn.send_transaction({"to": TO, "value": 5, "data": "0x"}) + message = str(exc.value) + assert other in message and w3.provider.hash_at(1) in message + + def test_wait_for_receipt_returns_none_on_timeout_and_dict_on_success(): from aleo_bridge.eth import Ethereum diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py index 20c0be58..e68eb500 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_execute.py +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -8,7 +8,7 @@ from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DispatchReceipt, Status -from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge, tx_hash_for +from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) @@ -44,7 +44,7 @@ def test_native_eth_dispatch_is_one_transaction_with_value(): receipt = result.receipt assert receipt.status == Status.DELIVERY_PENDING and receipt.protocol == "hyperlane" assert result.message_id == Web3.to_hex(MESSAGE_ID) and receipt.id == result.message_id - assert receipt.source_tx_id == tx_hash_for(1) == result.transaction_id and result.amount_atomic == 100 + assert receipt.source_tx_id == w3.provider.hash_at(1) == result.transaction_id and result.amount_atomic == 100 assert receipt.protocol_state == { "routeId": "hyperlane:ethereum/eth->aleo/eth", "approvalTxIds": [], "sourceSender": ACCT.address, "recipientBytes32": ALEO_BYTES32, "destinationDomain": 1634493807, @@ -59,7 +59,7 @@ def test_wbtc_approves_exact_token_amount_then_dispatches_with_fee_value(): assert [t["to"] for t in sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(WBTC_ROUTER)] assert sent[0]["data"][2:].lower() == APPROVE + WBTC_ROUTER[2:].lower().rjust(64, "0") + format(100_000, "064x") assert sent[0]["value"] == 0 and sent[1]["value"] == 0xC350 - assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and result.receipt.source_tx_id == tx_hash_for(2) + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and result.receipt.source_tx_id == w3.provider.hash_at(2) def test_wbtc_sufficient_allowance_skips_approval(): @@ -78,7 +78,7 @@ def test_usdt_resets_non_zero_allowance_first(): assert sent[0]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + "0" * 64 assert sent[1]["data"][2:].lower() == APPROVE + USDT_ROUTER[2:].lower().rjust(64, "0") + format(1_000_000, "064x") assert sent[2]["data"][2:10] == TRANSFER_REMOTE - assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1), tx_hash_for(2)] + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1), w3.provider.hash_at(2)] def test_usdt_zero_allowance_needs_no_reset(): @@ -89,13 +89,13 @@ def test_usdt_zero_allowance_needs_no_reset(): def test_approval_timeout_is_pending_and_checkpointed_before_polling(): eth, w3 = setup(WBTC_ROUTER, quotes={WBTC_ROUTER: [(ZERO_ADDRESS, 50_000), (WBTC, 100_000)]}) - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) seen = [] result = eth.transfer_remote("wbtc", ALEO, amount_atomic=100_000).send( timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None - assert result.receipt.id == tx_hash_for(1) and result.message_id is None and len(w3.provider.sent) == 1 - assert [cp.source for cp in seen] == [{"approvalTransactionIds": [tx_hash_for(1)]}] + assert result.receipt.id == w3.provider.hash_at(1) and result.message_id is None and len(w3.provider.sent) == 1 + assert [cp.source for cp in seen] == [{"approvalTransactionIds": [w3.provider.hash_at(1)]}] assert seen[0].intent == {"source": {"chain": "ethereum", "asset": "wbtc"}, "destination": {"chain": "aleo", "asset": "wbtc"}, "bridgeProtocol": "hyperlane", "amount": "0.001", "recipient": ALEO, "sender": ACCT.address, "mintMode": "public"} @@ -103,13 +103,13 @@ def test_approval_timeout_is_pending_and_checkpointed_before_polling(): def test_dispatch_timeout_is_source_confirming_with_hash(): eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) seen = [] result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send( timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) - assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) - assert result.receipt.id == tx_hash_for(1) and "messageId" not in result.receipt.protocol_state - assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1)}] + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) + assert result.receipt.id == w3.provider.hash_at(1) and "messageId" not in result.receipt.protocol_state + assert [cp.source for cp in seen] == [{"transactionId": w3.provider.hash_at(1)}] def test_dispatch_id_survives_unrelated_log_before_it(): @@ -148,7 +148,7 @@ def test_dispatch_id_from_a_foreign_address_is_ignored(): eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) w3.provider.receipt_logs = lambda tx: [dispatch_id_log(WBTC, MESSAGE_ID, tx_hash=tx["hash"], log_index=1)] result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) - assert result.message_id is None and result.receipt.id == tx_hash_for(1) + assert result.message_id is None and result.receipt.id == w3.provider.hash_at(1) assert result.receipt.status == Status.DELIVERY_PENDING and "messageId" not in result.receipt.protocol_state @@ -167,10 +167,10 @@ def logs(tx): def test_missing_dispatch_id_log_keeps_tx_hash_as_id(): - eth, _ = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + eth, w3 = setup(ETH_ROUTER, with_dispatch_log=False, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) result = eth.transfer_remote("eth", ALEO, amount_atomic=100).send(poll_seconds=0.001) assert result.receipt.status == Status.DELIVERY_PENDING and result.message_id is None - assert result.receipt.id == tx_hash_for(1) and "messageId" not in result.receipt.protocol_state + assert result.receipt.id == w3.provider.hash_at(1) and "messageId" not in result.receipt.protocol_state def test_build_lists_approval_then_dispatch_without_sending(): diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py index da6e43f3..6f165c95 100644 --- a/bridge-sdk/tests/test_eth_xreserve_execute.py +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -11,7 +11,7 @@ from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DepositReceipt, Status -from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge, tx_hash_for +from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) @@ -68,10 +68,10 @@ def test_record_mode_deposit_derives_nonce_payload_and_message_hash(): assert sent[0]["data"][2:].lower() == APPROVE + XRESERVE[2:].lower().rjust(64, "0") + format(2_000_000, "064x") receipt = result.receipt assert receipt.status == Status.ATTESTATION_PENDING and receipt.protocol == "xreserve" - assert receipt.source_tx_id == tx_hash_for(2) == result.transaction_id + assert receipt.source_tx_id == w3.provider.hash_at(2) == result.transaction_id hook = b"\x01" + bytes(64) recipient32 = encoding.aleo_address_to_bytes32(ALEO) - nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(tx_hash_for(2)[2:]), 3) + nonce = encoding.xreserve_deposit_nonce(0, bytes.fromhex(w3.provider.hash_at(2)[2:]), 3) payload = encoding.xreserve_deposit_payload(amount=2_000_000, remote_domain=10002, remote_token=REMOTE_TOKEN, remote_recipient=recipient32, local_token=USDC, depositor=ACCT.address, max_fee=100_000, nonce=nonce, hook_data=hook) @@ -81,7 +81,7 @@ def test_record_mode_deposit_derives_nonce_payload_and_message_hash(): assert result.nonce == "0x" + nonce.hex() == receipt.protocol_state["nonce"] assert receipt.protocol_state["payload"] == "0x" + payload.hex() state = receipt.protocol_state - assert state["routeId"] == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and state["approvalTxIds"] == [tx_hash_for(1)] + assert state["routeId"] == "xreserve:sepolia/usdc->aleo-testnet/usdcx" and state["approvalTxIds"] == [w3.provider.hash_at(1)] assert state["sourceSender"] == ACCT.address and state["mintMode"] == "record" and state["intendedRecipient"] == ALEO assert state["xReserveContract"] == Web3.to_checksum_address(XRESERVE) and state["tokenAddress"] == Web3.to_checksum_address(USDC) assert state["sourceChainId"] == 11155111 and state["sourceDomain"] == 0 and state["remoteDomain"] == 10002 @@ -89,9 +89,9 @@ def test_record_mode_deposit_derives_nonce_payload_and_message_hash(): assert state["amountAtomic"] == "2000000" and state["maxFeeAtomic"] == "100000" and state["depositLogIndex"] == 3 assert state["bridgeProgram"] == "test_usdcx_bridge_v2.aleo" and state["wrapperProgram"] == "shielded_usdcx_wrapper.aleo" assert [cp.source for cp in seen] == [ - {"approvalTransactionIds": [tx_hash_for(1)], "hookData": "0x" + hook.hex()}, - {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2), "hookData": "0x" + hook.hex()}, - {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2), "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2), "hookData": "0x" + hook.hex()}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2), "hookData": "0x" + hook.hex()}, ] assert seen[-1].id == message_hash and seen[-1].intent["mintMode"] == "record" @@ -166,17 +166,17 @@ def logs(tx): def test_timeouts_return_pending_receipts(): eth, w3 = setup() - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001) assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None - assert result.receipt.id == tx_hash_for(1) and result.message_hash == "" and result.nonce == "" + assert result.receipt.id == w3.provider.hash_at(1) and result.message_hash == "" and result.nonce == "" assert len(w3.provider.sent) == 1 eth, w3 = setup(allowance=5_000_000) - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) seen = [] result = eth.deposit_usdc(ALEO, amount="2").send(timeout_seconds=0.01, poll_seconds=0.001, on_checkpoint=seen.append) - assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) - assert [cp.source for cp in seen] == [{"transactionId": tx_hash_for(1), "hookData": "0x" + "00" * 65}] + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) + assert [cp.source for cp in seen] == [{"transactionId": w3.provider.hash_at(1), "hookData": "0x" + "00" * 65}] def test_plan_driven_deposit_is_identical_to_the_recipient_driven_one(): @@ -212,6 +212,6 @@ def test_plan_driven_deposit_rejects_a_tampered_stale_or_foreign_plan(): def test_reverted_deposit_raises(): eth, w3 = setup(allowance=5_000_000) - w3.provider.reverted.add(tx_hash_for(1)) + w3.provider.reverted_nth.add(1) with pytest.raises(BridgeError, match="reverted"): eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index 5e8823a8..c0b7c1ad 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -10,7 +10,7 @@ from aleo_bridge.eth import Ethereum, _plan_for from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import DispatchReceipt, Receipt, Status -from tests.fakes.fake_web3 import fake_web3, tx_hash_for +from tests.fakes.fake_web3 import fake_web3 KEY = "0x" + "11" * 32 ACCT = Account.from_key(KEY) @@ -146,8 +146,8 @@ def test_send_runs_approve_then_main_and_checkpoints_each_hash_before_polling(): # Make both receipts resolve only after a couple of pending polls, so the ordering # test actually exercises "checkpoint fires, THEN polling happens" rather than a # same-tick resolution that would pass even if the code checkpointed after polling. - w3.provider.receipt_delay[tx_hash_for(1)] = 2 - w3.provider.receipt_delay[tx_hash_for(2)] = 2 + w3.provider.receipt_delay_nth[1] = 2 + w3.provider.receipt_delay_nth[2] = 2 call, plan = make_call(w3) seen = [] poll_counts_at_checkpoint = [] @@ -160,29 +160,29 @@ def on_checkpoint(cp): assert isinstance(result, DispatchReceipt) and result.receipt.status == Status.DELIVERY_PENDING assert [t["to"] for t in w3.provider.sent] == [Web3.to_checksum_address(WBTC), Web3.to_checksum_address(ROUTER)] assert w3.provider.sent[1]["value"] == 50_000 - assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and result.receipt.source_tx_id == tx_hash_for(2) + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and result.receipt.source_tx_id == w3.provider.hash_at(2) assert [cp.source for cp in seen] == [ - {"approvalTransactionIds": [tx_hash_for(1)]}, - {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2)}, - {"approvalTransactionIds": [tx_hash_for(1)], "transactionId": tx_hash_for(2)}, + {"approvalTransactionIds": [w3.provider.hash_at(1)]}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2)}, + {"approvalTransactionIds": [w3.provider.hash_at(1)], "transactionId": w3.provider.hash_at(2)}, ] assert all(cp.route == {"id": plan.route_id, "registryVersion": plan.registry_version} for cp in seen) assert seen[0].intent["sender"] == ACCT.address # Checkpoint-before-poll ordering, per hash: # cp0 (approval broadcast) fires before any eth_getTransactionReceipt for hash1. - assert poll_counts_at_checkpoint[0].get(tx_hash_for(1), 0) == 0 - assert tx_hash_for(2) not in poll_counts_at_checkpoint[0] + assert poll_counts_at_checkpoint[0].get(w3.provider.hash_at(1), 0) == 0 + assert w3.provider.hash_at(2) not in poll_counts_at_checkpoint[0] # cp1 (main broadcast) fires after hash1 was fully polled to confirmation, but # before any eth_getTransactionReceipt for hash2. - assert poll_counts_at_checkpoint[1].get(tx_hash_for(1), 0) > 0 - assert poll_counts_at_checkpoint[1].get(tx_hash_for(2), 0) == 0 + assert poll_counts_at_checkpoint[1].get(w3.provider.hash_at(1), 0) > 0 + assert poll_counts_at_checkpoint[1].get(w3.provider.hash_at(2), 0) == 0 # cp2 (confirmed) fires only after hash2 has itself been polled. - assert poll_counts_at_checkpoint[2].get(tx_hash_for(2), 0) > 0 + assert poll_counts_at_checkpoint[2].get(w3.provider.hash_at(2), 0) > 0 # ... and each hash's poll count strictly increases after its own checkpoint fired # (the receipt_delay=2 knob forces at least one more poll beyond the checkpoint tick). - assert w3.provider.receipt_poll_counts[tx_hash_for(1)] > poll_counts_at_checkpoint[0].get(tx_hash_for(1), 0) - assert w3.provider.receipt_poll_counts[tx_hash_for(2)] > poll_counts_at_checkpoint[1].get(tx_hash_for(2), 0) + assert w3.provider.receipt_poll_counts[w3.provider.hash_at(1)] > poll_counts_at_checkpoint[0].get(w3.provider.hash_at(1), 0) + assert w3.provider.receipt_poll_counts[w3.provider.hash_at(2)] > poll_counts_at_checkpoint[1].get(w3.provider.hash_at(2), 0) # Full RPC sequence: approve is sent and fully confirmed (>=1 receipt poll) before # the main call is ever broadcast, and the main call is polled only afterwards. @@ -198,19 +198,19 @@ def on_checkpoint(cp): def test_approval_timeout_returns_pending_and_stops(): w3 = fake_web3() - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) call, _ = make_call(w3) result = call.send(timeout_seconds=0.01, poll_seconds=0.001) assert result.receipt.status == Status.SOURCE_APPROVAL_PENDING and result.receipt.source_tx_id is None - assert result.receipt.protocol_state["approvalTxIds"] == [tx_hash_for(1)] and len(w3.provider.sent) == 1 + assert result.receipt.protocol_state["approvalTxIds"] == [w3.provider.hash_at(1)] and len(w3.provider.sent) == 1 def test_main_timeout_returns_source_confirming(): w3 = fake_web3() - w3.provider.pending.add(tx_hash_for(1)) + w3.provider.pending_nth.add(1) call, _ = make_call(w3, approvals=0) result = call.send(timeout_seconds=0.01, poll_seconds=0.001) - assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == tx_hash_for(1) + assert result.receipt.status == Status.SOURCE_CONFIRMING and result.receipt.source_tx_id == w3.provider.hash_at(1) def test_wait_false_returns_after_first_broadcast(): @@ -222,10 +222,11 @@ def test_wait_false_returns_after_first_broadcast(): def test_reverted_transaction_raises(): w3 = fake_web3() - w3.provider.reverted.add(tx_hash_for(1)) + w3.provider.reverted_nth.add(1) call, _ = make_call(w3) - with pytest.raises(BridgeError, match=f"EVM transaction reverted: {tx_hash_for(1)}"): + with pytest.raises(BridgeError) as exc: call.send(poll_seconds=0.001) + assert str(exc.value) == f"EVM transaction reverted: {w3.provider.hash_at(1)}" def test_plan_sender_must_match_connected_account(): @@ -274,17 +275,31 @@ def test_store_failure_after_broadcast_reports_the_tx_hash_and_never_hides_it(): with pytest.raises(BridgeError) as exc: call.send(on_checkpoint=seen.append, poll_seconds=0.001) message = str(exc.value) - assert tx_hash_for(1) in message and "broadcast" in message and "checkpoint" in message.lower() - assert [cp.id for cp in seen] == [tx_hash_for(1)] # callback ran before the store - assert [cp.id for cp in store.attempts] == [tx_hash_for(1)] + assert w3.provider.hash_at(1) in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [w3.provider.hash_at(1)] # callback ran before the store + assert [cp.id for cp in store.attempts] == [w3.provider.hash_at(1)] assert len(w3.provider.sent) == 1 # broadcast happened exactly once assert "eth_getTransactionReceipt" not in w3.provider.methods # nothing polled after the failure +def test_a_mismatched_echoed_hash_stops_the_call_before_any_checkpoint(): + """Checkpointing the node's hash would strand the real transaction under an id nobody can find.""" + w3 = fake_web3() + w3.provider.echo_hashes[1] = "0x" + "ab" * 32 + store = ExplodingStore() + call, _ = make_call(w3, store=store, approvals=0) + seen = [] + with pytest.raises(BridgeError) as exc: + call.send(on_checkpoint=seen.append, poll_seconds=0.001) + assert "0x" + "ab" * 32 in str(exc.value) and w3.provider.hash_at(1) in str(exc.value) + assert seen == [] and store.attempts == [] + assert "eth_getTransactionReceipt" not in w3.provider.methods + + def test_bound_store_saves_every_checkpoint(tmp_path): w3 = fake_web3() store = FileCheckpointStore(tmp_path) call, _ = make_call(w3, store=store) result = call.send(poll_seconds=0.001) ids = {cp.id for cp in store.list()} - assert tx_hash_for(1) in ids and result.receipt.id in ids + assert w3.provider.hash_at(1) in ids and result.receipt.id in ids diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py index 1642ed70..534e17ba 100644 --- a/bridge-sdk/tests/test_sol_send.py +++ b/bridge-sdk/tests/test_sol_send.py @@ -26,7 +26,6 @@ RECIPIENT = TRANSFER["recipientAleoAddress"] AMOUNT = TRANSFER["amountLamports"] -SIGNATURE = str(STUB_SIGNATURE) CHECKPOINT_SOURCE_KEYS = {"transactionId", "blockhash", "lastValidBlockHeight"} @@ -102,11 +101,12 @@ def test_send_adds_the_fee_payer_signature_confirms_and_extracts_the_message_id( mod, fake, keypair = module() result = mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send() assert isinstance(result, DispatchReceipt) - assert result.transaction_id == SIGNATURE and result.route_id == sl.SOLANA_ROUTE_ID + signature = fake.sent_signature() + assert result.transaction_id == signature and result.route_id == sl.SOLANA_ROUTE_ID assert result.message_id == EXPECTED_MESSAGE_ID and result.amount_atomic == AMOUNT receipt = result.receipt assert receipt.status is Status.DELIVERY_PENDING and receipt.id == EXPECTED_MESSAGE_ID - assert receipt.source_tx_id == SIGNATURE and receipt.protocol == "hyperlane" + assert receipt.source_tx_id == signature and receipt.protocol == "hyperlane" assert receipt.protocol_state["messageId"] == EXPECTED_MESSAGE_ID assert "messageIdUnavailable" not in receipt.protocol_state assert len(fake.sent) == 1 @@ -189,10 +189,10 @@ def on_checkpoint(checkpoint): mod.transfer_remote(RECIPIENT, amount_atomic=AMOUNT).send(on_checkpoint=on_checkpoint) assert len(seen) == 1 checkpoint = seen[0] - assert checkpoint.id == SIGNATURE + assert checkpoint.id == fake.sent_signature() assert checkpoint.route == {"id": sl.SOLANA_ROUTE_ID, "registryVersion": mod.registry.version} assert set(checkpoint.source) == CHECKPOINT_SOURCE_KEYS - assert checkpoint.source["transactionId"] == SIGNATURE + assert checkpoint.source["transactionId"] == fake.sent_signature() assert checkpoint.source["blockhash"] == str(BLOCKHASH) and checkpoint.source["lastValidBlockHeight"] == "100" assert fake.calls.index("send_raw_transaction") < fake.calls.index("checkpoint") < fake.calls.index("get_signature_statuses") @@ -216,7 +216,7 @@ def delete(self, checkpoint_id): mod, fake, _ = module(checkpoints=RecordingStore()) seen = [] mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) - assert [cp.id for cp in saved] == [SIGNATURE] == [cp.id for cp in seen] + assert [cp.id for cp in saved] == [fake.sent_signature()] == [cp.id for cp in seen] def test_store_failure_after_broadcast_reports_the_signature_and_never_hides_it(): @@ -228,24 +228,26 @@ def test_store_failure_after_broadcast_reports_the_signature_and_never_hides_it( with pytest.raises(BridgeError) as excinfo: mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) message = str(excinfo.value) - assert SIGNATURE in message and "broadcast" in message and "checkpoint" in message.lower() - assert [cp.id for cp in seen] == [SIGNATURE] # callback ran before the store - assert [cp.id for cp in store.attempts] == [SIGNATURE] + signature = fake.sent_signature() + assert signature in message and "broadcast" in message and "checkpoint" in message.lower() + assert [cp.id for cp in seen] == [signature] # callback ran before the store + assert [cp.id for cp in store.attempts] == [signature] assert len(fake.sent) == 1 # broadcast happened exactly once assert "get_signature_statuses" not in fake.calls # nothing polled after the failure def test_send_failed_status_raises_naming_the_signature(): - mod, _, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [2, "Custom"]})])) - with pytest.raises(BridgeError, match=SIGNATURE): + mod, fake, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(err={"InstructionError": [2, "Custom"]})])) + with pytest.raises(BridgeError) as excinfo: mod.transfer_remote(RECIPIENT, amount_atomic=1).send() + assert fake.sent_signature() in str(excinfo.value) def test_send_timeout_returns_a_pending_source_confirming_receipt(): mod, fake, _ = module(FakeSolanaClient(statuses=[None])) result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) receipt = result.receipt - assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == SIGNATURE + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() assert result.message_id is None and "messageId" not in receipt.protocol_state assert "get_transaction" not in fake.calls and len(fake.sent) == 1 @@ -256,7 +258,7 @@ def test_send_expired_blockhash_returns_expired_without_resubmitting(): receipt = result.receipt assert receipt.status is Status.EXPIRED assert receipt.protocol_state["blockhashExpired"] is True - assert SIGNATURE in receipt.protocol_state["sourceError"] + assert fake.sent_signature() in receipt.protocol_state["sourceError"] assert len(fake.sent) == 1 @@ -266,7 +268,7 @@ def test_processed_is_never_reported_expired_and_skips_the_blockhash_probe(): mod, _, _ = module(fake) result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) receipt = result.receipt - assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == SIGNATURE + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() assert "blockhashExpired" not in receipt.protocol_state assert "is_blockhash_valid" not in fake.calls assert len(fake.sent) == 1 @@ -278,7 +280,7 @@ def test_log_fetch_failure_after_confirmation_degrades_to_message_id_unavailable mod, _, _ = module(fake) result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() receipt = result.receipt - assert receipt.status is Status.DELIVERY_PENDING and receipt.id == SIGNATURE + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == fake.sent_signature() assert receipt.protocol_state["messageIdUnavailable"] is True assert "messageId" not in receipt.protocol_state and result.message_id is None assert len(fake.sent) == 1 @@ -304,10 +306,10 @@ def test_send_without_wait_skips_polling(): def test_finalized_counts_as_confirmed_and_missing_log_marks_message_id_unavailable(): - mod, _, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], logs=[])) + mod, fake, _ = module(FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="finalized")], logs=[])) result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send() receipt = result.receipt - assert receipt.status is Status.DELIVERY_PENDING and receipt.id == SIGNATURE + assert receipt.status is Status.DELIVERY_PENDING and receipt.id == fake.sent_signature() assert receipt.protocol_state["messageIdUnavailable"] is True and result.message_id is None processed_then_confirmed = FakeSolanaClient(statuses=[FakeSignatureStatus(confirmation_status="processed"), FakeSignatureStatus()]) mod2, _, _ = module(processed_then_confirmed) @@ -320,3 +322,29 @@ def test_send_requires_a_signer(): with pytest.raises(ConfigurationError, match="read-only"): read_only.transfer_remote(RECIPIENT, amount_atomic=1).build() assert fake.sent == [] + + +def test_a_lost_send_response_names_the_local_signature_and_never_polls_or_checkpoints(): + """The bytes may already be on the wire: losing the RPC answer must not lose the signature.""" + fake = FakeSolanaClient(send_error=RuntimeError("connection reset by peer")) + mod, _, _ = module(fake) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + assert fake.sent_signature() in message # the id the caller needs to investigate + assert "may have been broadcast" in message and "connection reset by peer" in message + assert fake.calls.count("send_raw_transaction") == 1 # exactly one attempt, never retried + assert "get_signature_statuses" not in fake.calls and seen == [] + + +def test_a_node_signature_that_differs_from_the_signed_one_is_refused_without_checkpointing(): + """Checkpointing the node's id would follow — and later resend — the wrong transaction.""" + fake = FakeSolanaClient(signature=STUB_SIGNATURE) + mod, _, _ = module(fake) + seen = [] + with pytest.raises(BridgeError) as excinfo: + mod.transfer_remote(RECIPIENT, amount_atomic=1).send(on_checkpoint=seen.append) + message = str(excinfo.value) + assert fake.sent_signature() in message and str(STUB_SIGNATURE) in message + assert seen == [] and "get_signature_statuses" not in fake.calls From f6ce2c1a3e0fe74d33504786e810dc4091f1de83 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:24:54 -0400 Subject: [PATCH 63/94] fix(bridge-sdk): a call is single-use once it has broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SolCall.send() and EvmCall.send() refuse a second send once any transaction of theirs has reached the node, naming the id to follow instead. The approval and main broadcasts of one send() are unaffected; a failure before the first broadcast (a stale plan, an insufficient balance, a failed read) leaves the call usable, and build() stays repeatable. A send whose RPC response was lost also arms the guard on both chains — the bytes may be on the wire, which is exactly when a retry would double-spend. --- bridge-sdk/python/aleo_bridge/_calls.py | 45 +++++++++++++++- bridge-sdk/python/aleo_bridge/eth.py | 7 ++- bridge-sdk/python/aleo_bridge/sol.py | 7 ++- bridge-sdk/tests/test_evm_call.py | 68 +++++++++++++++++++++++-- bridge-sdk/tests/test_sol_send.py | 46 +++++++++++++++++ 5 files changed, 165 insertions(+), 8 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index 7bd0b3b8..4d242f88 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -253,6 +253,18 @@ def __init__(self, conn: Any, *, plan: "Plan", registry: "Registry", store: "CheckpointStore | None" = None) -> None: self._conn, self.plan, self._registry = conn, plan, registry self._steps, self._finish, self._store = steps, finish, store + self._broadcast_id: str | None = None + + def _record_broadcast(self, tx_hash: str) -> None: + """Arm the single-use guard with the FIRST hash this call put on the wire.""" + if self._broadcast_id is None: + self._broadcast_id = tx_hash + + def _refuse_a_second_send(self) -> None: + if self._broadcast_id is not None: + raise BridgeError( + f"this call already broadcast {self._broadcast_id}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") def _sender(self) -> str: sender = self._conn.require_address() @@ -303,11 +315,26 @@ def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_second ``wait=False`` broadcasts only the first step and returns its pending result; call ``bridge.eth.source_status`` (or plan 4's ``resume``) to continue. + + A call is single-use once ANY of its steps has broadcast: a second ``send()`` raises rather + than re-approving and re-dispatching the same funds. The approval and main broadcasts of one + ``send()`` are of course fine, and a failure before the first broadcast (a validation error, a + failed read) leaves the call usable. ``build()`` stays repeatable — it spends nothing. """ + self._refuse_a_second_send() sender = self._sender() approvals: list[str] = [] for step in self._steps(sender): - tx_hash = self._conn.send_transaction({"from": sender, "to": step.to, "data": step.data, "value": step.value}) + try: + tx_hash = self._conn.send_transaction({"from": sender, "to": step.to, "data": step.data, "value": step.value}) + except BridgeError as exc: + # A send whose RPC response was lost may still have reached the node — arm the guard + # with the hash it named, so a retry cannot turn an ambiguous send into a double spend. + lost = getattr(exc, "broadcast_id", None) + if lost is not None: + self._record_broadcast(str(lost)) + raise + self._record_broadcast(tx_hash) if step.kind == "approve": approvals.append(tx_hash) pending = self._finish(EvmOutcome("SOURCE_APPROVAL_PENDING", sender, tuple(approvals), None, None)) @@ -356,6 +383,12 @@ def __init__(self, module: Any, *, route: Any, recipient: str, amount_atomic: in self._store = store self.quote: Any = None self._built: Any = None + self._broadcast_id: str | None = None + + def _record_broadcast(self, signature: str) -> None: + """Arm the single-use guard with the signature this call put on the wire (or may have).""" + if self._broadcast_id is None: + self._broadcast_id = signature def build(self) -> Any: """Partially signed ``VersionedTransaction`` (unique-message signer only); sets ``self.quote``.""" @@ -366,10 +399,18 @@ def build(self) -> Any: def send(self, *, wait: bool = True, timeout_seconds: float = 120.0, poll_seconds: float = 1.0, on_checkpoint: Callable[[Any], None] | None = None) -> R: + """Broadcast once. A call is single-use from the moment its transaction reaches the node (or + may have, when the RPC response was lost): a second ``send()`` raises rather than signing a + second transfer of the same funds. A failure before the broadcast — a stale plan, an + insufficient balance — leaves the call usable, and ``build()`` stays repeatable.""" + if self._broadcast_id is not None: + raise BridgeError( + f"this call already broadcast {self._broadcast_id}; use bridge.sol.source_status(plan, receipt) " + "to follow it — do not resend") self.build() receipt = self._module._submit(self._built, wait=wait, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, on_checkpoint=on_checkpoint, - store=self._store) + store=self._store, on_broadcast=self._record_broadcast) return self._build_result(receipt) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index addbf3d5..29796501 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -189,9 +189,12 @@ def send_transaction(self, tx: dict) -> str: try: echoed = Web3.to_hex(self._w3.eth.send_raw_transaction(signed.raw_transaction)) except Exception as exc: # noqa: BLE001 — any transport/JSON-RPC failure loses the response, not the send - raise BridgeError( + error = BridgeError( f"Ethereum transaction {local_hash} may have been broadcast; the RPC response was lost: {exc}" - " — check bridge.eth.source_status / the explorer before retrying") from exc + " — check bridge.eth.source_status / the explorer before retrying") + # EvmCall reads this to arm its single-use guard: an ambiguous send must not be retried. + error.broadcast_id = local_hash # type: ignore[attr-defined] + raise error from exc if echoed.lower() != local_hash.lower(): raise BridgeError( f"Ethereum RPC echoed transaction hash {echoed} for a transaction signed as {local_hash}; " diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 2f3c3dee..9ac3e8ed 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -791,7 +791,8 @@ def _checkpoint(self, plan: Plan, receipt: Receipt, on_checkpoint: "Callable[[Ch def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_seconds: float, on_checkpoint: "Callable[[Checkpoint], None] | None" = None, - store: "CheckpointStore | None" = None) -> Receipt: + store: "CheckpointStore | None" = None, + on_broadcast: "Callable[[str], None] | None" = None) -> Receipt: libs = _libs() quote = built.quote balance = self._balance_of(built.sender) @@ -812,9 +813,13 @@ def _submit(self, built: SolBuild, *, wait: bool, timeout_seconds: float, poll_s try: response = self.client.send_raw_transaction(bytes(signed), opts=opts) except Exception as exc: # noqa: BLE001 — any transport/decoding failure loses the response, not the send + if on_broadcast is not None: + on_broadcast(signature) # it may be on the wire: never let the caller resend raise BridgeError( f"Solana transaction {signature} may have been broadcast; the RPC response was lost: {exc}" " — check bridge.sol.source_status / the explorer before retrying") from exc + if on_broadcast is not None: + on_broadcast(signature) echoed = str(response.value) if echoed != signature: raise BridgeError( diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index c0b7c1ad..4cf2c341 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -21,13 +21,13 @@ USDC_ROUTE = DEFAULT_REGISTRY.route("xreserve:ethereum/usdc->aleo/usdcx") -def make_call(w3, *, sender=None, store=None, approvals=1): +def make_call(w3, *, sender=None, store=None, approvals=1, steps=None): conn = Ethereum(w3=w3, private_key=KEY) plan = _plan_for(DEFAULT_REGISTRY, ROUTE, amount_atomic=100_000, recipient=ALEO, sender=sender) token = w3.eth.contract(address=Web3.to_checksum_address(WBTC), abi=ERC20_ABI) warp = w3.eth.contract(address=Web3.to_checksum_address(ROUTER), abi=WARP_ROUTE_ABI) - def steps(owner): + def default_steps(owner): out = [EvmStep("approve", token.address, token.encode_abi("approve", args=[warp.address, 100_000]), 0) for _ in range(approvals)] out.append(EvmStep("main", warp.address, warp.encode_abi("transferRemote", args=[1634493807, b"\x11" * 32, 100_000]), 50_000)) @@ -41,7 +41,8 @@ def finish(outcome: EvmOutcome) -> DispatchReceipt: "sourceSender": outcome.sender}) return DispatchReceipt(transaction_id=rid, route_id=ROUTE.id, message_id=None, amount_atomic=100_000, receipt=receipt) - return EvmCall(conn, plan=plan, registry=DEFAULT_REGISTRY, steps=steps, finish=finish, store=store), plan + return EvmCall(conn, plan=plan, registry=DEFAULT_REGISTRY, steps=steps or default_steps, + finish=finish, store=store), plan def test_plan_for_hyperlane_and_xreserve_shapes(): @@ -282,6 +283,67 @@ def test_store_failure_after_broadcast_reports_the_tx_hash_and_never_hides_it(): assert "eth_getTransactionReceipt" not in w3.provider.methods # nothing polled after the failure +def test_a_call_is_single_use_once_it_has_broadcast(): + """Re-sending the same call would spend the approval and dispatch a second transfer.""" + w3 = fake_web3() + call, _ = make_call(w3, approvals=0) + call.send(poll_seconds=0.001) + tx_hash = w3.provider.hash_at(1) + with pytest.raises(BridgeError) as exc: + call.send(poll_seconds=0.001) + assert str(exc.value) == (f"this call already broadcast {tx_hash}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") + assert len(w3.provider.sent) == 1 + + +def test_the_guard_is_armed_by_the_first_broadcast_of_a_multi_step_send(): + """The approval alone is enough: a resend would re-approve and re-dispatch.""" + w3 = fake_web3() + w3.provider.pending_nth.add(1) + call, _ = make_call(w3) # approval + main + call.send(timeout_seconds=0.01, poll_seconds=0.001) # stops pending after the approval + with pytest.raises(BridgeError, match=f"already broadcast {w3.provider.hash_at(1)}"): + call.send(poll_seconds=0.001) + assert len(w3.provider.sent) == 1 + + +def test_a_lost_send_response_also_arms_the_single_use_guard(): + w3 = fake_web3() + w3.provider.send_errors[1] = "connection reset by peer" + call, _ = make_call(w3, approvals=0) + with pytest.raises(BridgeError, match="may have been broadcast"): + call.send(poll_seconds=0.001) + with pytest.raises(BridgeError, match="already broadcast"): + call.send(poll_seconds=0.001) + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 + + +def test_a_failure_before_any_broadcast_leaves_the_call_usable(): + w3 = fake_web3() + failures = [] + + def steps(owner): + if not failures: + failures.append(owner) + raise BridgeError("router quote unavailable") # a read failed; nothing was sent + return [EvmStep("main", Web3.to_checksum_address(ROUTER), "0x", 0)] + + call, _ = make_call(w3, steps=steps) + with pytest.raises(BridgeError, match="router quote unavailable"): + call.send() + assert w3.provider.sent == [] + result = call.send(poll_seconds=0.001) # the guard was never armed + assert result.receipt.status == Status.DELIVERY_PENDING and len(w3.provider.sent) == 1 + + +def test_build_stays_repeatable_after_a_send(): + w3 = fake_web3() + call, _ = make_call(w3, approvals=0) + call.send(poll_seconds=0.001) + assert [t["to"] for t in call.build()] == [Web3.to_checksum_address(ROUTER)] # a preview never spends + assert len(w3.provider.sent) == 1 + + def test_a_mismatched_echoed_hash_stops_the_call_before_any_checkpoint(): """Checkpointing the node's hash would strand the real transaction under an id nobody can find.""" w3 = fake_web3() diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py index 534e17ba..e375802b 100644 --- a/bridge-sdk/tests/test_sol_send.py +++ b/bridge-sdk/tests/test_sol_send.py @@ -324,6 +324,52 @@ def test_send_requires_a_signer(): assert fake.sent == [] +def test_a_call_is_single_use_once_it_has_broadcast(): + """Re-sending the same call would sign a second transfer of the same funds.""" + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + call.send() + signature = fake.sent_signature() + with pytest.raises(BridgeError) as excinfo: + call.send() + message = str(excinfo.value) + assert message == (f"this call already broadcast {signature}; use bridge.sol.source_status(plan, receipt) " + "to follow it — do not resend") + assert len(fake.sent) == 1 + + +def test_a_lost_send_response_also_arms_the_single_use_guard(): + """The bytes may be on the wire; a resend is exactly what must not happen next.""" + fake = FakeSolanaClient(send_error=RuntimeError("connection reset by peer")) + mod, _, _ = module(fake) + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(BridgeError, match="may have been broadcast"): + call.send() + with pytest.raises(BridgeError, match="already broadcast"): + call.send() + assert len(fake.sent) == 1 + + +def test_a_failure_before_any_broadcast_leaves_the_call_usable(): + fake = FakeSolanaClient(balance=0) + mod, _, _ = module(fake) + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + with pytest.raises(InsufficientBalanceError): + call.send() + assert fake.sent == [] + fake.balance = 800_000_000_000 + result = call.send() # the guard was never armed + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.sent) == 1 + + +def test_build_stays_repeatable_after_a_send(): + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + call.send() + assert isinstance(call.build(), VersionedTransaction) # a preview never spends + assert len(fake.sent) == 1 + + def test_a_lost_send_response_names_the_local_signature_and_never_polls_or_checkpoints(): """The bytes may already be on the wire: losing the RPC answer must not lose the signature.""" fake = FakeSolanaClient(send_error=RuntimeError("connection reset by peer")) From 6c86e0d519af2d01b1b3011ed78c773a2dfab36e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:25:36 -0400 Subject: [PATCH 64/94] fix(bridge-sdk): a Solana status row with no confirmation level is processed, not unknown getSignatureStatuses answering with a row whose err and confirmationStatus are both null means the node has seen the transaction. _signature_status now reports 'processed' for it and reserves None for a missing row, so neither _poll_for_confirmation nor source_status probes blockhash expiry on a transfer that already landed and reports it EXPIRED. --- bridge-sdk/python/aleo_bridge/sol.py | 12 ++++++++++-- bridge-sdk/tests/test_sol_send.py | 11 +++++++++++ bridge-sdk/tests/test_sol_status.py | 12 ++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 9ac3e8ed..9fff104a 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -487,7 +487,13 @@ class SolBuild: def _signature_status(client: Any, signature: Any) -> str | None: - """'failed' | 'processed' | 'confirmed' | 'finalized' | None (unknown); raises on RPC errors.""" + """'failed' | 'processed' | 'confirmed' | 'finalized' | None (unknown); raises on RPC errors. + + ``None`` means one thing only: the node has NO row for this signature. A row that exists but + carries no ``confirmationStatus`` (and no ``err``) is a transaction the node has seen — processed + at least — so it reports ``'processed'``. Collapsing that into ``None`` would send both callers to + the blockhash probe and let a landed transfer be reported EXPIRED, inviting a resend. + """ value = client.get_signature_statuses([signature], search_transaction_history=True).value status = value[0] if value else None if status is None: @@ -495,7 +501,9 @@ def _signature_status(client: Any, signature: Any) -> str | None: if getattr(status, "err", None) is not None: return "failed" name = _confirmation_name(status) - if name not in (None, "processed", "confirmed", "finalized"): + if name is None: + return "processed" + if name not in ("processed", "confirmed", "finalized"): raise BridgeError(f"Solana RPC getSignatureStatuses returned unsupported confirmation status: {name}") return name diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py index e375802b..d7d2c606 100644 --- a/bridge-sdk/tests/test_sol_send.py +++ b/bridge-sdk/tests/test_sol_send.py @@ -274,6 +274,17 @@ def test_processed_is_never_reported_expired_and_skips_the_blockhash_probe(): assert len(fake.sent) == 1 +def test_a_status_row_without_a_confirmation_level_is_never_reported_expired(): + """err=None with no confirmationStatus means the transaction landed; only a MISSING row is unknown.""" + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(err=None, confirmation_status=None)], blockhash_valid=False) + mod, _, _ = module(fake) + result = mod.transfer_remote(RECIPIENT, amount_atomic=1).send(timeout_seconds=0) + receipt = result.receipt + assert receipt.status is Status.SOURCE_CONFIRMING and receipt.id == fake.sent_signature() + assert "blockhashExpired" not in receipt.protocol_state + assert "is_blockhash_valid" not in fake.calls and len(fake.sent) == 1 + + def test_log_fetch_failure_after_confirmation_degrades_to_message_id_unavailable(): """The logs only carry the message id: an RPC failure there must not fail a settled transfer.""" fake = FakeSolanaClient(get_transaction_error=RuntimeError("rpc")) diff --git a/bridge-sdk/tests/test_sol_status.py b/bridge-sdk/tests/test_sol_status.py index 573c2689..7e9b71c0 100644 --- a/bridge-sdk/tests/test_sol_status.py +++ b/bridge-sdk/tests/test_sol_status.py @@ -49,6 +49,18 @@ def test_processed_and_unknown_without_lifetime_are_unchanged(): assert "is_blockhash_valid" not in fake2.calls +def test_a_status_row_without_a_confirmation_level_counts_as_processed(): + """A row with err=None and no confirmationStatus means the transaction EXISTS (processed at + least). Treating it as 'unknown' would probe the blockhash and report EXPIRED on a landed + transfer — an invitation to resend.""" + lifetime = {"blockhash": str(BLOCKHASH), "lastValidBlockHeight": "100"} + fake = FakeSolanaClient(statuses=[FakeSignatureStatus(err=None, confirmation_status=None)], blockhash_valid=False) + mod, _, plan = setup(fake) + original = receipt(plan, **lifetime) + assert mod.source_status(plan, original) == original + assert "is_blockhash_valid" not in fake.calls + + def test_unknown_signature_with_lifetime_checks_the_blockhash(): lifetime = {"blockhash": str(BLOCKHASH), "lastValidBlockHeight": "100"} mod, fake, plan = setup(FakeSolanaClient(statuses=[None], blockhash_valid=True)) From 9f67ee3eb4063de92b5b6fe40a6f81060d95e2dd Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:26:38 -0400 Subject: [PATCH 65/94] fix(bridge-sdk): coerce solana= like ethereum=, and derive the status row from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/python/aleo_bridge/client.py | 38 +++++++++++++++++++++---- bridge-sdk/tests/test_sol_bridge.py | 32 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index af439efc..672bcefd 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -80,6 +80,23 @@ def _coerce_ethereum(value: Any) -> Ethereum | None: raise ConfigurationError("ethereum= must be an aleo_bridge.Ethereum connection or a web3.Web3 instance") +def _coerce_solana(value: Any) -> Solana | None: + """Accept a ``Solana``, an RPC URL string, or a bare solana-py ``Client`` (wrapped read-only). + + Mirrors :func:`_coerce_ethereum`: anything else is a configuration mistake caught here rather + than as an ``AttributeError`` from the first RPC read. + """ + if value is None or isinstance(value, Solana): + return value + if isinstance(value, str): + return Solana(rpc_url=value) + if callable(getattr(value, "get_latest_blockhash", None)) and callable(getattr(value, "get_account_info", None)): + return Solana(client=value) # spec §3: a bare client is a read-only connection + raise ConfigurationError( + "solana= must be an aleo_bridge.Solana connection, an RPC URL string, or a solana-py Client " + "(an object with get_latest_blockhash and get_account_info)") + + def solana_from_env() -> Any: """``Solana.from_env()``: SOLANA_PRIVATE_KEY/BRIDGE_SOLANA_PRIVATE_KEY (+ SOLANA_RPC_URL/ BRIDGE_LIVE_SOLANA_RPC_URL) or None; a key alone signs, a URL alone is read-only, neither → None.""" @@ -121,9 +138,8 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") self.checkpoints = checkpoints self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) - if solana is not None and not isinstance(solana, Solana): - solana = Solana(client=solana) # bare solana-py Client → read-only connection (spec §3) - self.solana: Solana | None = solana + self.solana: Solana | None = _coerce_solana(solana) + solana = self.solana self._sol: SolModule | None = SolModule(self, solana) if solana is not None else None self.profile: Profile | None = None self._eth: EthModule | None = None @@ -163,6 +179,11 @@ def aleo_chain(self) -> Chain: raise ConfigurationError(f"Registry must define exactly one Aleo chain for {self.environment}") return chains[0] + def solana_chain(self) -> Chain | None: + """The environment's Solana chain, or None — only mainnet has one.""" + chains = [c for c in self.registry.chains(environment=self.environment) if c.family == "solana"] + return chains[0] if chains else None + def aleo_address(self) -> str: account = getattr(self.aleo, "default_account", None) if not account: @@ -253,9 +274,14 @@ def status(self) -> BridgeStatus: chains = [self._aleo_chain_status()] if self.ethereum is not None: chains.append(self.eth.chain_status()) - if self.solana is not None: - balances = {"solana/sol": self.sol.balance()} if self.solana.address is not None else {} - chains.append(ChainStatus(chain_id="solana", address=self.solana.address, + solana_chain = self.solana_chain() + if self.solana is not None and solana_chain is not None: + # Chain id and asset id come from the registry, not literals: a testnet client (no Solana + # chain at all) reports no Solana row rather than one naming a chain this environment lacks. + native = next((a for a in self.registry.assets(chain=solana_chain.id) if a.kind == "native"), None) + balances = ({native.id: self.sol.balance()} + if native is not None and self.solana.address is not None else {}) + chains.append(ChainStatus(chain_id=solana_chain.id, address=self.solana.address, can_sign=self.solana.can_sign, balances=balances)) pending: list["Progress"] = [] return BridgeStatus(environment=self.environment, registry_version=self.registry.version, diff --git a/bridge-sdk/tests/test_sol_bridge.py b/bridge-sdk/tests/test_sol_bridge.py index c5e3e7e6..38c9ac67 100644 --- a/bridge-sdk/tests/test_sol_bridge.py +++ b/bridge-sdk/tests/test_sol_bridge.py @@ -43,6 +43,38 @@ def test_configured_connection_reports_sol_balance_in_status(): assert solana_status.balances == {"solana/sol": 1_234} +def test_an_rpc_url_string_becomes_a_read_only_connection(): + bridge = Bridge(FakeAleo(), solana="https://rpc.example") + assert isinstance(bridge.solana, Solana) and bridge.solana.rpc_url == "https://rpc.example" + assert bridge.solana.can_sign is False + + +def test_an_object_that_is_not_a_solana_client_is_refused(): + class NotAClient: + def get_latest_blockhash(self, commitment=None): # pragma: no cover - never called + return None + + with pytest.raises(ConfigurationError, match="solana="): + Bridge(FakeAleo(), solana=NotAClient()) # no get_account_info + with pytest.raises(ConfigurationError, match="solana="): + Bridge(FakeAleo(), solana=object()) + + +def test_status_omits_the_solana_row_when_the_environment_has_no_solana_chain(): + """Only mainnet has a Solana chain; a testnet client must not invent a "solana" row.""" + bridge = Bridge(FakeAleo(network_name="testnet"), solana=FakeSolanaClient()) + assert [c.chain_id for c in bridge.status().chains if c.chain_id == "solana"] == [] + + +def test_status_derives_the_solana_chain_and_native_asset_from_the_registry(): + keypair = Keypair() + bridge = Bridge(FakeAleo(), solana=Solana(client=FakeSolanaClient(balance=7), signer=keypair)) + chain = [c for c in bridge.registry.chains(environment="mainnet") if c.family == "solana"][0] + native = [a for a in bridge.registry.assets(chain=chain.id) if a.kind == "native"][0] + row = [c for c in bridge.status().chains if c.chain_id == chain.id][0] + assert row.balances == {native.id: 7} + + def test_from_env_builds_the_solana_connection(monkeypatch): key = b58encode(bytes(Keypair())) seen = {} From 02ebe97cf1bdf53603a21d91afbe98ccc4686178 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:27:43 -0400 Subject: [PATCH 66/94] fix(bridge-sdk): close Solana transports properly and never raise from __exit__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- bridge-sdk/python/aleo_bridge/sol.py | 34 +++++++++++++-- bridge-sdk/tests/test_sol_connection.py | 57 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 9fff104a..9d0b13e2 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -241,6 +241,13 @@ def send_raw_transaction(self, txn: bytes, opts: Any = None) -> RpcResult: raise BridgeError("Solana RPC sendTransaction returned an invalid signature") return RpcResult(_libs().Signature.from_string(result)) + def close(self) -> None: + """Release the HTTP session (and its connection pool). Idempotent; ``requests`` allows reuse + afterwards, so a closed client that is used again simply opens fresh connections.""" + closer = getattr(self._session, "close", None) + if callable(closer): + closer() + def get_signature_statuses(self, signatures: Sequence[Any], search_transaction_history: bool = False) -> RpcResult: value = self._contextual("getSignatureStatuses", self._call( "getSignatureStatuses", [[str(s) for s in signatures], {"searchTransactionHistory": bool(search_transaction_history)}])) @@ -296,12 +303,25 @@ def _run(self, coroutine: Any) -> Any: return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() def close(self, timeout: float = 5.0) -> None: - """Stop the private event-loop thread and close the loop. Idempotent — a second call is a no-op.""" + """Close the wrapped client, then stop the private loop thread. Idempotent. + + The wrapped client's own ``close()`` runs FIRST and on our loop: solana-py's ``AsyncClient`` + owns an aiohttp session that can only be closed from the loop it was created on, so stopping + the thread first would leak the connection pool. The loop itself is closed only once the + thread has actually exited — closing a running loop raises. + """ if self._closed: return self._closed = True + closer = getattr(self._client, "close", None) + if callable(closer): + result = closer() + if inspect.isawaitable(result): + self._run(result) self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join(timeout=timeout) + if self._thread.is_alive(): + return # still running: leave the loop alone rather than raise if not self._loop.is_closed(): self._loop.close() @@ -450,8 +470,11 @@ def sign_message(self, message: bytes) -> Any: return self._signer.sign_message(bytes(message)) def close(self) -> None: - """Release the wrapped client's resources (idempotent). A no-op unless the client exposes its own - ``close()`` — e.g. the private event-loop thread behind an adapted async solana-py client.""" + """Release the wrapped client's resources (idempotent). + + The default transport closes its HTTP session; an adapted async solana-py client closes the + client itself and then its private event-loop thread. A client with no ``close()`` is a no-op. + ``__exit__`` swallows whatever this raises; call it directly to see the error.""" closer = getattr(self._client, "close", None) if callable(closer): closer() @@ -460,7 +483,10 @@ def __enter__(self) -> "Solana": return self def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: - self.close() + try: + self.close() + except Exception: # noqa: BLE001 — releasing a transport must never + pass # replace (or invent) the caller's own exception def _confirmation_name(status: Any) -> str | None: diff --git a/bridge-sdk/tests/test_sol_connection.py b/bridge-sdk/tests/test_sol_connection.py index 0e36a1be..412b52c9 100644 --- a/bridge-sdk/tests/test_sol_connection.py +++ b/bridge-sdk/tests/test_sol_connection.py @@ -209,6 +209,63 @@ async def get_balance(self, pubkey, commitment=None): assert adapter._thread.is_alive() is False +def test_adapter_close_awaits_the_wrapped_clients_own_close_on_its_loop(): + """solana-py's AsyncClient owns an aiohttp session that can only be closed from its loop; + stopping the thread first would leak it (and warn).""" + pytest.importorskip("solders") + + class FakeAsyncClient: + def __init__(self): + self.closed_on = None + + async def get_balance(self, pubkey, commitment=None): + return sol.RpcResult(7) + + async def close(self): + import threading as _threading + self.closed_on = _threading.current_thread().name + + fake = FakeAsyncClient() + adapter = sol._AsyncClientAdapter(fake) + loop_thread = adapter._thread.name + adapter.close() + assert fake.closed_on == loop_thread # awaited on the private loop, before it stopped + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: closes the client exactly once + + +def test_solana_exit_never_raises_even_when_the_client_close_fails(): + pytest.importorskip("solders") + + class ExplodingClient: + def get_latest_blockhash(self, commitment=None): # pragma: no cover - never called + return None + + def close(self): + raise OSError("socket already gone") + + with sol.Solana(client=ExplodingClient()) as conn: + assert conn.client is not None + with pytest.raises(OSError): # an explicit close() still reports it + sol.Solana(client=ExplodingClient()).close() + + +def test_rpc_client_close_closes_its_session(): + pytest.importorskip("solders") + + class FakeSession: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + session = FakeSession() + conn = sol.Solana(client=sol.SolanaRpcClient("https://rpc.example", session=session)) + conn.close() + assert session.closed == 1 # the default transport releases its pool + + def test_close_is_a_noop_for_a_connection_without_a_closeable_client(): conn = sol.Solana(client=_Reader()) conn.close() # no close() on the client — must not raise From 19797dc4975f401d450084779e305682d1d1a912 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:31:01 -0400 Subject: [PATCH 67/94] fix(bridge-sdk): keep the key out of tracebacks, re-resolve the route at build, accept a plan alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/python/aleo_bridge/_calls.py | 2 +- bridge-sdk/python/aleo_bridge/sol.py | 85 ++++++++++++++++++++----- bridge-sdk/tests/test_sol_quote.py | 50 +++++++++++++++ bridge-sdk/tests/test_sol_send.py | 26 ++++++++ 4 files changed, 146 insertions(+), 17 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/_calls.py b/bridge-sdk/python/aleo_bridge/_calls.py index 4d242f88..ade2dc76 100644 --- a/bridge-sdk/python/aleo_bridge/_calls.py +++ b/bridge-sdk/python/aleo_bridge/_calls.py @@ -392,7 +392,7 @@ def _record_broadcast(self, signature: str) -> None: def build(self) -> Any: """Partially signed ``VersionedTransaction`` (unique-message signer only); sets ``self.quote``.""" - self._built = self._module._build_transaction(route=self.route, recipient=self.recipient, + self._built = self._module._build_transaction(recipient=self.recipient, amount_atomic=self.amount_atomic, plan=self.plan) self.quote = self._built.quote return self._built.transaction diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index 9d0b13e2..d809e94b 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -29,6 +29,7 @@ ConfigurationError, InsufficientBalanceError, InvalidAmountError, + InvalidRecipientError, MissingExtraError, RegistryVersionMismatchError, RouteNotFoundError, @@ -371,20 +372,32 @@ def keypair_from_private_key(private_key: str | bytes) -> Any: if isinstance(private_key, (bytes, bytearray, memoryview)): raw = bytes(private_key) else: + # Every failure below raises OUTSIDE its except block, so neither __cause__ nor __context__ is + # set: a chained JSONDecodeError carries the whole document in .doc, and a solders parse error + # may echo its input — either one would print the private key with the traceback. text = private_key.strip() if text.startswith("["): + values: Any = None + malformed = False try: values = json.loads(text) - except ValueError as exc: - raise ConfigurationError("Solana private key JSON array is malformed; expected the 64 integers of a solana-cli id.json") from exc + except ValueError: + malformed = True + if malformed: + raise ConfigurationError( + "Solana private key JSON array is malformed; expected the 64 integers of a solana-cli id.json") if not isinstance(values, list) or not all(isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255 for v in values): raise ConfigurationError("Solana private key JSON array must hold integers 0–255") raw = bytes(values) else: + keypair = None try: - return libs.Keypair.from_base58_string(text) - except Exception as exc: # solders raises its own parse error types - raise ConfigurationError("Solana private key is not a valid base58 64-byte secret") from exc + keypair = libs.Keypair.from_base58_string(text) + except Exception: # noqa: BLE001 — solders raises its own parse error types + keypair = None + if keypair is None: + raise ConfigurationError("Solana private key is not a valid base58 64-byte secret") + return keypair if len(raw) == 64: return libs.Keypair.from_bytes(raw) if len(raw) == 32: @@ -498,6 +511,17 @@ def _confirmation_name(status: Any) -> str | None: return str(name).rsplit(".", 1)[-1].lower() +def _assert_amount_matches_plan(plan: Plan, *, amount: str | None, amount_atomic: int | None, decimals: int) -> None: + """A plan pins the amount. Re-stating the same one is harmless; a different one is a mistake the + caller must see, not a silent override of the plan they prepared (``EthModule``'s rule).""" + if amount is None and amount_atomic is None: + return + given = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) + if given != plan.amount_atomic: + raise ValueError(f"plan is for {plan.amount_atomic} atomic units, but {given} was passed; " + "pass plan= alone or re-run quote() for the amount you want") + + @dataclass class SolBuild: """Everything ``send`` needs after ``build``: the quote, the compiled message, the partially signed @@ -654,23 +678,34 @@ def _compile_message(self, metadata: sl.SolanaRouteMetadata, *, sender: str, uni ) return message, str(latest.blockhash), int(latest.last_valid_block_height) - def quote_transfer_remote(self, recipient: str, *, amount: str | None = None, amount_atomic: int | None = None, - sender: str | None = None, plan: Plan | None = None) -> SolanaHyperlaneQuote: + def quote_transfer_remote(self, recipient: str | None = None, *, amount: str | None = None, + amount_atomic: int | None = None, sender: str | None = None, + plan: Plan | None = None) -> SolanaHyperlaneQuote: """Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind ``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required - for the fee estimate; ``plan`` (from ``Bridge.quote``) pins recipient/amount/sender and must match the live - registry version.""" + for the fee estimate. + + ``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry + version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ + ``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so + re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + """ libs = _libs() route = self.outbound_route() metadata = sl.solana_route_metadata(route) decimals = self.registry.asset(route.source_asset_id).decimals if plan is not None: + if sender is not None: + raise ValueError("Pass plan= or sender=, not both: the plan carries its own sender") if plan.registry_version != self.registry.version: raise RegistryVersionMismatchError( f"plan was prepared against registry {plan.registry_version}; this client runs {self.registry.version} — re-run quote()") if plan.route_id != route.id: raise UnsupportedRouteError(f"plan route {plan.route_id} is not the Solana Hyperlane route {route.id}") + _assert_amount_matches_plan(plan, amount=amount, amount_atomic=amount_atomic, decimals=decimals) recipient, amount_atomic, amount, sender = plan.recipient, plan.amount_atomic, None, plan.sender + elif recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) if amount_atomic <= 0: raise InvalidAmountError("amount must be positive") @@ -709,41 +744,59 @@ def quote_transfer_remote(self, recipient: str, *, amount: str | None = None, am # --- write ------------------------------------------------------------------------------ - def transfer_remote(self, recipient: str, *, amount: str | None = None, amount_atomic: int | None = None, - plan: Plan | None = None) -> SolCall[DispatchReceipt]: + def transfer_remote(self, recipient: str | None = None, *, amount: str | None = None, + amount_atomic: int | None = None, plan: Plan | None = None) -> SolCall[DispatchReceipt]: """Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, ``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). - ``plan`` (from ``Bridge.execute``) must have been prepared for the connected wallet; its - registry version and route id are re-checked against the live registry when the call runs. + + ``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for + the connected wallet; its registry version and route id are re-checked against the live registry + when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a + ``ValueError``. Without a plan, ``recipient`` is required. """ route = self.outbound_route() sl.solana_route_metadata(route) # refuse inactive/malformed routes early decimals = self.registry.asset(route.source_asset_id).decimals if plan is not None: + _assert_amount_matches_plan(plan, amount=amount, amount_atomic=amount_atomic, decimals=decimals) recipient, amount_atomic, amount = plan.recipient, plan.amount_atomic, None + elif recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") amount_atomic = resolve_amount(amount=amount, amount_atomic=amount_atomic, decimals=decimals) if amount_atomic <= 0: raise InvalidAmountError("amount must be positive") aleo_address_to_bytes32(recipient) def build_result(receipt: Receipt) -> DispatchReceipt: - return DispatchReceipt(transaction_id=receipt.source_tx_id or receipt.id, route_id=route.id, + # The receipt's routeId comes from the route resolved at build time, which is the one the + # transaction was actually compiled against; route.id here is only the snapshot's fallback. + return DispatchReceipt(transaction_id=receipt.source_tx_id or receipt.id, + route_id=receipt.protocol_state.get("routeId") or route.id, message_id=receipt.protocol_state.get("messageId"), amount_atomic=amount_atomic, receipt=receipt) return SolCall(self, route=route, recipient=recipient, amount_atomic=amount_atomic, plan=plan, build_result=build_result, store=getattr(self._bridge, "checkpoints", None)) - def _build_transaction(self, *, route: Route, recipient: str, amount_atomic: int, plan: Plan | None) -> SolBuild: + def _build_transaction(self, *, recipient: str, amount_atomic: int, plan: Plan | None) -> SolBuild: libs = _libs() sender = self.conn.address if sender is None: raise ConfigurationError("Solana connection is read-only: pass signer= or private_key= to Solana() to build transactions") if plan is not None and plan.sender and plan.sender != sender: raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {sender}") - quote = self.quote_transfer_remote(recipient, amount_atomic=amount_atomic, sender=sender, plan=plan) + quote = (self.quote_transfer_remote(plan=plan) if plan is not None # the plan carries the sender + else self.quote_transfer_remote(recipient, amount_atomic=amount_atomic, sender=sender)) + # Re-resolved here rather than reusing the route snapshotted when transfer_remote() was called: + # the registry may have moved since, and compiling the instruction against a stale deployment + # while the quote priced the live one would sign a transfer to the wrong program. + route = self.outbound_route() + if route.id != quote.plan.route_id: + raise UnsupportedRouteError( + f"the Solana Hyperlane route is now {route.id}, but this transfer was quoted for " + f"{quote.plan.route_id} — re-run quote()") metadata = sl.solana_route_metadata(route) unique = libs.Keypair() # fresh per build: seeds the dispatched-message and gas-payment PDAs message, blockhash, last_valid_block_height = self._compile_message( diff --git a/bridge-sdk/tests/test_sol_quote.py b/bridge-sdk/tests/test_sol_quote.py index 950461c1..4f658383 100644 --- a/bridge-sdk/tests/test_sol_quote.py +++ b/bridge-sdk/tests/test_sol_quote.py @@ -106,6 +106,56 @@ def test_quote_with_plan_checks_registry_version_and_reuses_the_plan(): mod.quote_transfer_remote(RECIPIENT, plan=stale) +def test_a_plan_alone_supplies_recipient_amount_and_sender(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + quoted = mod.quote_transfer_remote(plan=plan) # no positional recipient + assert quoted.plan is plan and quoted.plan.sender == SENDER + assert quoted.total_lamports == 5 + EXPECTED_IGP_PAYMENT_LAMPORTS + NETWORK_FEE_LAMPORTS + RENT + + +def test_a_plan_is_mutually_exclusive_with_sender_and_a_differing_amount(): + mod, _ = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=5, sender=SENDER).plan + with pytest.raises(ValueError, match="plan"): + mod.quote_transfer_remote(plan=plan, sender=SENDER) + with pytest.raises(ValueError, match="plan"): + mod.quote_transfer_remote(plan=plan, amount_atomic=6) + assert mod.quote_transfer_remote(plan=plan, amount_atomic=5).plan is plan # identical is fine + + +def test_neither_a_plan_nor_a_recipient_is_refused(): + mod, fake = module() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + mod.quote_transfer_remote(amount_atomic=1, sender=SENDER) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + mod.transfer_remote(amount_atomic=1) + assert fake.sent == [] + + +def test_a_malformed_json_private_key_never_carries_the_secret_into_the_traceback(): + """A chained JSONDecodeError keeps the whole document in .doc — which IS the private key.""" + from aleo_bridge.sol import keypair_from_private_key + + secret = "[17,42,99,128" # a truncated solana-cli id.json + with pytest.raises(ConfigurationError) as excinfo: + keypair_from_private_key(secret) + exc = excinfo.value + assert exc.__cause__ is None and exc.__context__ is None + assert "17" not in repr(exc) and secret not in repr(exc) + + +def test_a_malformed_base58_private_key_never_carries_the_secret_into_the_traceback(): + from aleo_bridge.sol import keypair_from_private_key + + secret = "5JueXBoJHvOoPeKeYsEcReT" + with pytest.raises(ConfigurationError) as excinfo: + keypair_from_private_key(secret) + exc = excinfo.value + assert exc.__cause__ is None and exc.__context__ is None + assert secret not in repr(exc) + + def test_balance_reads_the_connected_wallet(): keypair = Keypair() mod, fake = module(FakeSolanaClient(balance=42), signer=keypair) diff --git a/bridge-sdk/tests/test_sol_send.py b/bridge-sdk/tests/test_sol_send.py index d7d2c606..69bb7390 100644 --- a/bridge-sdk/tests/test_sol_send.py +++ b/bridge-sdk/tests/test_sol_send.py @@ -27,6 +27,7 @@ RECIPIENT = TRANSFER["recipientAleoAddress"] AMOUNT = TRANSFER["amountLamports"] CHECKPOINT_SOURCE_KEYS = {"transactionId", "blockhash", "lastValidBlockHeight"} +OTHER_WARP_PROGRAM_ADDRESS = "6HCbFm2P3NWG8SKhzvMLgQHBJAjZvBrbfP6uFtQKpyfd" # a redeployed warp route @pytest.fixture(autouse=True) @@ -335,6 +336,31 @@ def test_send_requires_a_signer(): assert fake.sent == [] +def test_transfer_remote_accepts_a_plan_without_a_recipient(): + mod, fake, keypair = module() + plan = mod.quote_transfer_remote(RECIPIENT, amount_atomic=3).plan + result = mod.transfer_remote(plan=plan).send() # no positional recipient + assert result.receipt.status is Status.DELIVERY_PENDING and len(fake.sent) == 1 + with pytest.raises(ValueError, match="plan"): + mod.transfer_remote(plan=plan, amount_atomic=4) + assert mod.transfer_remote(plan=plan, amount_atomic=3) is not None # identical is fine + + +def test_build_compiles_against_the_re_resolved_route_not_the_one_snapshotted_at_transfer_remote(): + """The registry can move between transfer_remote() and build(); building the instruction from the + stale snapshot while the quote used the fresh route would sign against the wrong program.""" + mod, fake, _ = module() + call = mod.transfer_remote(RECIPIENT, amount_atomic=1) + original = mod.outbound_route() + moved = dataclasses.replace(original, metadata={**original.metadata, + "warpProgramAddress": OTHER_WARP_PROGRAM_ADDRESS}) + mod.outbound_route = lambda: moved # the live registry now says otherwise + transaction = call.build() + programs = [str(transaction.message.account_keys[ix.program_id_index]) for ix in transaction.message.instructions] + assert OTHER_WARP_PROGRAM_ADDRESS in programs and WARP_PROGRAM_ADDRESS not in programs + assert fake.sent == [] + + def test_a_call_is_single_use_once_it_has_broadcast(): """Re-sending the same call would sign a second transfer of the same funds.""" mod, fake, _ = module() From df5d34a3520c8d96d7305b4a0a71aeffa3048536 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:31:40 -0400 Subject: [PATCH 68/94] docs(bridge-sdk): document the Solana env aliases; drop two dead declarations 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. --- bridge-sdk/README.md | 11 ++++++----- bridge-sdk/python/aleo_bridge/_sealevel.py | 1 - bridge-sdk/python/aleo_bridge/sol.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index f84d5994..1c4704de 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -104,11 +104,12 @@ default if it rate-limits. The funded round trip runs from `scripts/rehearse.py` `BRIDGE_PRIVATE_KEY` (required by `from_env`), `ALEO_ENDPOINT` (default `https://edge.provable.com/api`), `ALEO_NETWORK` (`mainnet`|`testnet`), `ALEO_API_KEY`/`ALEO_CONSUMER_ID` (legacy hosts), `EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL` (aliases `BRIDGE_EVM_PRIVATE_KEY`+`BRIDGE_LIVE_ETHEREUM_RPC_URL`, used by the -user's live shell/veil config; the primary variable wins when both are set), `SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`), -`BRIDGE_CHECKPOINT_DIR`. -Note that `BRIDGE_LIVE_ETHEREUM_RPC_URL` is not live-test-only: ordinary `Ethereum.from_env()` / -`Bridge.from_env()` read it as an alias for `ETHEREUM_RPC_URL`, so leaving it exported points everyday -calls at that endpoint too. +user's live shell/veil config; the primary variable wins when both are set), +`SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`) (aliases `BRIDGE_SOLANA_PRIVATE_KEY`+`BRIDGE_LIVE_SOLANA_RPC_URL`, +same precedence), `BRIDGE_CHECKPOINT_DIR`. +Note that `BRIDGE_LIVE_ETHEREUM_RPC_URL` and `BRIDGE_LIVE_SOLANA_RPC_URL` are not live-test-only: ordinary +`Ethereum.from_env()` / `Solana.from_env()` / `Bridge.from_env()` read them as aliases for +`ETHEREUM_RPC_URL` / `SOLANA_RPC_URL`, so leaving one exported points everyday calls at that endpoint too. Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo key (mode 600). ## Tests diff --git a/bridge-sdk/python/aleo_bridge/_sealevel.py b/bridge-sdk/python/aleo_bridge/_sealevel.py index 1502e179..c89379ed 100644 --- a/bridge-sdk/python/aleo_bridge/_sealevel.py +++ b/bridge-sdk/python/aleo_bridge/_sealevel.py @@ -24,7 +24,6 @@ TRANSFER_REMOTE_VARIANT_TAG = 1 INSTRUCTION_DATA_BYTES = 77 # 8 + 1 + 4 + 32 + 32 U256_BYTES = 32 -ALEO_MAINNET_HYPERLANE_DOMAIN = 1634493807 def build_transfer_remote_instruction_data(destination_domain: int, recipient32: bytes, amount: int) -> bytes: diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index d809e94b..b4804dff 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -658,7 +658,7 @@ def balance(self) -> int: # --- quote ------------------------------------------------------------------------------ - def _make_plan(self, route: Route, *, recipient: str, amount_atomic: int, sender: str, decimals: int) -> Plan: + def _make_plan(self, route: Route, *, recipient: str, amount_atomic: int, sender: str) -> Plan: return build_plan(self.registry, route, amount_atomic=amount_atomic, recipient=recipient, sender=sender) def _compile_message(self, metadata: sl.SolanaRouteMetadata, *, sender: str, unique_message: str, @@ -714,7 +714,7 @@ def quote_transfer_remote(self, recipient: str | None = None, *, amount: str | N if sender is None: raise ConfigurationError("Solana sender is required to quote the transaction fee: configure a signer or pass sender=") if plan is None: - plan = self._make_plan(route, recipient=recipient, amount_atomic=amount_atomic, sender=sender, decimals=decimals) + plan = self._make_plan(route, recipient=recipient, amount_atomic=amount_atomic, sender=sender) igp_data = self._account_data(metadata.igp_account) if igp_data is None: From 43d0934b5253dfee61efd51bdf9f7dfc4ba4e31d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:37:48 -0400 Subject: [PATCH 69/94] fix(bridge-sdk): name the missing recipient, and validate log_scan_chunk_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. --- bridge-sdk/python/aleo_bridge/eth.py | 26 +++++++++++++++++-- .../tests/test_eth_hyperlane_execute.py | 11 +++++++- bridge-sdk/tests/test_eth_hyperlane_quote.py | 9 +++++++ bridge-sdk/tests/test_eth_recover.py | 21 +++++++++++++-- bridge-sdk/tests/test_eth_xreserve_execute.py | 11 +++++++- bridge-sdk/tests/test_eth_xreserve_quote.py | 10 ++++++- 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 29796501..9be39dc4 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -317,9 +317,23 @@ def __init__(self, bridge: Any, conn: Ethereum, *, log_scan_chunk_blocks: int = self.registry: Registry = bridge.registry self.network: str = bridge.network # "mainnet" | "testnet" → aleo. for encoders self.chain: Chain = self.registry.chain(EVM_CHAIN_BY_ENVIRONMENT[bridge.environment]) - if int(log_scan_chunk_blocks) < 1: + self.log_scan_chunk_blocks = log_scan_chunk_blocks # recovery eth_getLogs span; lower it for strict RPCs + + @property + def log_scan_chunk_blocks(self) -> int: + """Blocks per ``eth_getLogs`` request during recovery scans; lower it for strict RPCs. + + Validated on every assignment, not just in the constructor: ``_scan_logs`` advances its + cursor by this many blocks per pass, so a zero or negative chunk would loop forever + against a live chain rather than fail. + """ + return self._log_scan_chunk_blocks + + @log_scan_chunk_blocks.setter + def log_scan_chunk_blocks(self, value: Any) -> None: + if int(value) < 1: raise ConfigurationError("log_scan_chunk_blocks must be at least 1") - self.log_scan_chunk_blocks = int(log_scan_chunk_blocks) # recovery eth_getLogs span; lower it for strict RPCs + self._log_scan_chunk_blocks = int(value) # -- resolution --------------------------------------------------------------------------- @@ -558,6 +572,8 @@ def quote_transfer_remote(self, asset: Any = None, recipient: str | None = None, amount, and is validated against the live registry. It is mutually exclusive with ``asset=``/``route=``/``sender=``. """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") if plan is not None: if asset is not None or route is not None or sender is not None: raise ValueError("Pass plan= or asset=/route=/sender=, not both") @@ -715,6 +731,8 @@ def quote_deposit_usdc(self, recipient: str | None = None, *, amount: Any = None and mint mode, and is validated against the live registry. It is mutually exclusive with ``route=``/``sender=``. """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") if plan is not None: if route is not None or sender is not None: raise ValueError("Pass plan= or route=/sender=, not both") @@ -795,6 +813,8 @@ def transfer_remote(self, asset: Any = None, recipient: str | None = None, *, am re-resolved by id against the live registry, the sender must be the connected account, and the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") if plan is not None: if asset is not None: raise ValueError("Pass plan= or asset=, not both") @@ -930,6 +950,8 @@ def deposit_usdc(self, recipient: str | None = None, *, amount: Any = None, amou the plan must equal what this call would have prepared itself. ``secret_nonce`` is never part of a plan, so a private deposit must still pass the same one it was quoted with. """ + if plan is None and recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") if plan is not None: route, sender, recipient, atomic, mint_mode = self._from_plan( plan, "xreserve", recipient=recipient, amount=amount, amount_atomic=amount_atomic, diff --git a/bridge-sdk/tests/test_eth_hyperlane_execute.py b/bridge-sdk/tests/test_eth_hyperlane_execute.py index e68eb500..99f721fe 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_execute.py +++ b/bridge-sdk/tests/test_eth_hyperlane_execute.py @@ -5,7 +5,8 @@ from eth_utils import keccak from web3 import Web3 -from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) +from aleo_bridge.errors import (BridgeError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DispatchReceipt, Status from tests.fakes.fake_web3 import ZERO_ADDRESS, dispatch_id_log, event_log, fake_web3, make_bridge @@ -221,3 +222,11 @@ def test_read_only_connection_cannot_transfer(): eth = make_bridge(ethereum=Ethereum(w3=w3)).eth with pytest.raises(ConfigurationError, match="read-only"): eth.transfer_remote("eth", ALEO, amount_atomic=100) + + +def test_transfer_remote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """Same guard as the quote path: a missing recipient is an InvalidRecipientError, not a TypeError.""" + eth, w3 = setup(ETH_ROUTER, quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.transfer_remote("eth", amount_atomic=100) + assert w3.provider.methods == [] and w3.provider.sent == [] diff --git a/bridge-sdk/tests/test_eth_hyperlane_quote.py b/bridge-sdk/tests/test_eth_hyperlane_quote.py index 0277878a..2bec8b06 100644 --- a/bridge-sdk/tests/test_eth_hyperlane_quote.py +++ b/bridge-sdk/tests/test_eth_hyperlane_quote.py @@ -144,3 +144,12 @@ def test_amount_and_recipient_validation(): eth.quote_transfer_remote("eth", "aleo1notanaddress", amount_atomic=1) with pytest.raises(InvalidRecipientError): eth.quote_transfer_remote("eth", "0x0000000000000000000000000000000000000001", amount_atomic=1) + + +def test_quote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """``recipient`` is only optional when ``plan=`` supplies it; otherwise it must be named, + not surface as the opaque TypeError ``_recipient_bytes32(None)`` used to raise.""" + eth, w3 = eth_module(quotes={ETH_ROUTER: [(ZERO_ADDRESS, 1_000)]}) + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.quote_transfer_remote("eth", amount_atomic=100) + assert w3.provider.methods == [] # refused before any contract read diff --git a/bridge-sdk/tests/test_eth_recover.py b/bridge-sdk/tests/test_eth_recover.py index 77dff2cb..75ab2b3c 100644 --- a/bridge-sdk/tests/test_eth_recover.py +++ b/bridge-sdk/tests/test_eth_recover.py @@ -6,8 +6,9 @@ from aleo_bridge import encoding from aleo_bridge.checkpoint import create_checkpoint -from aleo_bridge.errors import BridgeError, CheckpointInvalidError, RegistryVersionMismatchError -from aleo_bridge.eth import Ethereum, _plan_for +from aleo_bridge.errors import (BridgeError, CheckpointInvalidError, ConfigurationError, + RegistryVersionMismatchError) +from aleo_bridge.eth import LOG_SCAN_CHUNK_BLOCKS, EthModule, Ethereum, _plan_for from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import Receipt, Status from tests.fakes.fake_web3 import deposited_log, dispatch_id_log, fake_web3, make_bridge, sent_transfer_remote_log @@ -299,3 +300,19 @@ def test_xreserve_private_recovery_uses_checkpointed_hook_and_wrapper_recipient( bad = dataclasses.replace(cp, source={**cp.source, "hookData": "0x02"}) with pytest.raises(CheckpointInvalidError, match="hook data"): eth.recover_source(plan, bad) + + +def test_log_scan_chunk_blocks_cannot_be_lowered_below_one(): + """``_scan_logs`` advances by ``chunk`` blocks a pass, so a 0 (or negative) chunk would spin + forever on a live range: the attribute validates on assignment, exactly like the constructor + (``ConfigurationError``, the error the constructor has always raised for this — the plan's + ``ValueError`` wording is kept as the same single check rather than two different errors).""" + eth, _ = mainnet_read_only() + for bad in (0, -5): + with pytest.raises(ConfigurationError, match="at least 1"): + eth.log_scan_chunk_blocks = bad + assert eth.log_scan_chunk_blocks == LOG_SCAN_CHUNK_BLOCKS # the refused assignments changed nothing + eth.log_scan_chunk_blocks = 10 + assert eth.log_scan_chunk_blocks == 10 + with pytest.raises(ConfigurationError, match="at least 1"): + EthModule(make_bridge(ethereum=Ethereum(w3=fake_web3())), Ethereum(w3=fake_web3()), log_scan_chunk_blocks=0) diff --git a/bridge-sdk/tests/test_eth_xreserve_execute.py b/bridge-sdk/tests/test_eth_xreserve_execute.py index 6f165c95..450c2ae7 100644 --- a/bridge-sdk/tests/test_eth_xreserve_execute.py +++ b/bridge-sdk/tests/test_eth_xreserve_execute.py @@ -8,7 +8,8 @@ from web3 import Web3 from aleo_bridge import encoding -from aleo_bridge.errors import (BridgeError, ConfigurationError, RegistryVersionMismatchError, RouteUnavailableError) +from aleo_bridge.errors import (BridgeError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError, RouteUnavailableError) from aleo_bridge.eth import Ethereum from aleo_bridge.types import DepositReceipt, Status from tests.fakes.fake_web3 import deposited_log, fake_web3, make_bridge @@ -215,3 +216,11 @@ def test_reverted_deposit_raises(): w3.provider.reverted_nth.add(1) with pytest.raises(BridgeError, match="reverted"): eth.deposit_usdc(ALEO, amount="2").send(poll_seconds=0.001) + + +def test_deposit_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """Same guard as the quote path: a missing recipient is an InvalidRecipientError, not a TypeError.""" + eth, w3 = setup() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.deposit_usdc(amount="2") + assert w3.provider.methods == [] and w3.provider.sent == [] diff --git a/bridge-sdk/tests/test_eth_xreserve_quote.py b/bridge-sdk/tests/test_eth_xreserve_quote.py index adcf4061..532defd8 100644 --- a/bridge-sdk/tests/test_eth_xreserve_quote.py +++ b/bridge-sdk/tests/test_eth_xreserve_quote.py @@ -5,7 +5,7 @@ from aleo_bridge.encoding import aleo_address_to_bytes32, aleo_program_address, xreserve_hook_data from aleo_bridge.errors import (BridgeError, ChainMismatchError, ConfigurationError, InsufficientBalanceError, - InvalidAmountError) + InvalidAmountError, InvalidRecipientError) from aleo_bridge.eth import Ethereum from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import EvmXReserveQuote @@ -142,3 +142,11 @@ def test_non_digit_minimum_amount_atomic_is_refused_before_any_contract_read(): with pytest.raises(ConfigurationError, match="minimumAmountAtomic"): eth.quote_deposit_usdc(ALEO, amount="2", route=route) assert "eth_call" not in w3.provider.methods + + +def test_quote_without_a_plan_or_a_recipient_names_the_missing_recipient(): + """``recipient`` is only optional when ``plan=`` supplies it (the hook commits to it).""" + eth, w3 = sepolia() + with pytest.raises(InvalidRecipientError, match="recipient is required when no plan is given"): + eth.quote_deposit_usdc(amount="2") + assert w3.provider.methods == [] # refused before any contract read From 823178319ff5c40545d3a1981cf08a7b5f0d491e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:45:11 -0400 Subject: [PATCH 70/94] feat(bridge-sdk): execute() with prove->checkpoint->broadcast Aleo legs 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 248 ++++++++++++++++++++- bridge-sdk/tests/fakes/fake_bridge.py | 136 ++++++++--- bridge-sdk/tests/test_execute.py | 244 ++++++++++++++++++++ 3 files changed, 593 insertions(+), 35 deletions(-) create mode 100644 bridge-sdk/tests/test_execute.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index b2c700e2..3d3ed4f3 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -15,8 +15,10 @@ import re from dataclasses import dataclass, replace +from typing import Any, Callable from ._plan import build_plan +from .checkpoint import Checkpoint, create_checkpoint from .errors import ( CheckpointInvalidError, ConfigurationError, @@ -27,7 +29,8 @@ UnsupportedRouteError, ) from .registry import Asset, Chain, Registry, Route -from .types import AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, Quote +from .types import (TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, Progress, Quote, + Receipt, Status, to_progress) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount MINT_MODES = ("public", "record", "private") @@ -181,10 +184,7 @@ def quote(bridge, *, source, destination, amount=None, amount_atomic=None, recip q = _module(bridge, "eth").quote_transfer_remote(plan=plan) return replace(q, plan=plan) if plan.protocol == "hyperlane" and family == "solana": - # Unlike EthModule's quote methods, SolModule.quote_transfer_remote's ``recipient`` has - # no default — it is a required positional argument even though it is fully overwritten - # from ``plan`` on the real module's plan branch. - q = _module(bridge, "sol").quote_transfer_remote(plan.recipient, plan=plan) + q = _module(bridge, "sol").quote_transfer_remote(plan=plan) return replace(q, plan=plan) if plan.protocol == "hyperlane" and family == "aleo": gas = bridge.hyperlane.quote_gas_payment(plan.source_asset_id) @@ -219,4 +219,240 @@ def quote(bridge, *, source, destination, amount=None, amount_atomic=None, recip f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") -__all__ = ["MINT_MODES", "ResolvedRoute", "prepare", "quote", "resolve_route"] +# ── Checkpoint emission ─────────────────────────────────────────────────────── + +def _persist(bridge, checkpoint: Checkpoint, receipt: Receipt | None, *, previous_id: str | None = None) -> None: + """Mirror *checkpoint* into the bound store: supersede the previous id, drop terminal ones. + + ``receipt`` is ``None`` for a boundary the protocol module reduced and saved itself: the + supersede still runs (a module only ever saves — it never deletes the checkpoint its own + next boundary replaces), the save does not. + """ + store = getattr(bridge, "checkpoints", None) + if store is None: + return + if previous_id is not None and previous_id != checkpoint.id: + store.delete(previous_id) + if receipt is None: + return + if receipt.status in TERMINAL: + store.delete(checkpoint.id) + else: + store.save(checkpoint) + + +class _Emitter: + """Turns receipts into checkpoints: caller callback first, then the bound store. + + Two channels feed it — a protocol module's own ``on_checkpoint`` (which hands over a + ``Checkpoint`` it has already reduced and saved) and ``execute``'s own emission once the + send returns. A boundary that arrives through both is handed to the caller once: the two + reductions compare equal, being the same receipt reduced against the same plan. + """ + + def __init__(self, bridge, plan: Plan, on_checkpoint: Callable | None) -> None: + self._bridge, self._plan, self._cb = bridge, plan, on_checkpoint + self._last_id: str | None = None + self._last: Checkpoint | None = None + + def __call__(self, receipt) -> Checkpoint: + module_emitted = isinstance(receipt, Checkpoint) + checkpoint = receipt if module_emitted else create_checkpoint(self._plan, receipt, self._bridge.registry) + if checkpoint != self._last: + if hasattr(self._bridge, "events"): + # test hook: FakeBridge records the ordering of proving/checkpoint/broadcast + label = receipt.status.value if isinstance(receipt, Receipt) else "module" + self._bridge.events.append((f"checkpoint:{label}", checkpoint.id)) + if self._cb is not None: + self._cb(checkpoint) # the caller's own callback: errors are theirs + _persist(self._bridge, checkpoint, None if module_emitted else receipt, previous_id=self._last_id) + self._last_id, self._last = checkpoint.id, checkpoint + return checkpoint + + +# ── Execution helpers ───────────────────────────────────────────────────────── + +def _assert_sender(plan: Plan, address: str | None, *, family: str) -> None: + """Refuse a plan prepared for a different account than the one that would sign it. + + EVM addresses are hex and their checksum casing carries no identity, so they compare + case-insensitively; Solana addresses are base58, where case IS part of the address. + """ + if not plan.sender or not address: + return + same = plan.sender.lower() == address.lower() if family == "evm" else plan.sender == address + if not same: + raise ConfigurationError( + f"Plan sender {plan.sender} does not match the connected account {address}. " + "Re-quote with sender=None or the connection's own address.") + + +def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> int | None: + """The recipient's destination balance, or None when we cannot read it. + + Only read when the destination connection IS the recipient (there is no per-address balance + read in the module contracts); otherwise omit the delivery-verification pair rather than + baseline the wrong account. + """ + chain, asset = resolved.destination_chain, resolved.destination_asset + if chain.family == "evm": + conn = getattr(bridge, "ethereum", None) + if (conn is None or not conn.address + or conn.address.lower() != plan.recipient.lower() + or asset.locator is None or asset.locator.kind not in ("native", "evm-contract")): + return None + return int(bridge.eth.balance(asset.id)) + if chain.family == "solana": + conn = getattr(bridge, "solana", None) + if (conn is None or conn.address != plan.recipient + or asset.locator is None or asset.locator.kind != "native"): + return None + return int(bridge.sol.balance()) + return None # Aleo private records / token mappings: protocol signal instead + + +def _delivery_verification(bridge, plan: Plan, resolved: ResolvedRoute) -> dict[str, str]: + before = _read_destination_balance(bridge, plan, resolved) + if before is None: + return {} + expected = parse_decimal_amount(plan.amount, resolved.destination_asset.decimals) + return {"destinationBalanceBeforeAtomic": str(before), + "expectedDestinationIncreaseAtomic": str(expected)} + + +def _prepare_aleo(call, proving: str): + if proving == "delegate": + return call.delegate_prepared() + if proving == "local": + return call.prove() + raise ConfigurationError(f"proving must be 'delegate' (DPS) or 'local', got {proving!r}") + + +def _run_aleo_leg(bridge, plan: Plan, call, *, proving: str, emit: _Emitter, + extra_state: dict[str, Any]) -> Receipt: + """Invariant 3: prove → checkpoint the exact transaction → broadcast → checkpoint the id.""" + prepared = _prepare_aleo(call, proving) + emit(Receipt(id=prepared.transaction_id, protocol=plan.protocol, + status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared.serialized, + **extra_state})) + result = call.submit_prepared(prepared, wait=False) # polling is wait()'s job, not execute()'s + receipt: Receipt = result.receipt + receipt = receipt.replace(id=prepared.transaction_id, status=Status.SOURCE_CONFIRMING, + source_tx_id=prepared.transaction_id, + protocol_state={**receipt.protocol_state, "routeId": plan.route_id, **extra_state}) + emit(receipt) + return receipt + + +def _send_call(call, emit: _Emitter, poll_seconds: float, timeout_seconds: float) -> Receipt: + result = call.send(wait=True, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, + on_checkpoint=emit) + receipt: Receipt = result.receipt + emit(receipt) + return receipt + + +def _aleo_hyperlane_mode(mode: str | None) -> bool: + if mode is None or mode == "caller": + return False + if mode == "signer": + return True + raise ConfigurationError(f"Aleo Hyperlane mode must be 'caller' or 'signer', got {mode!r}") + + +def _xreserve_burn_mode(mode: str | None) -> str: + if mode is None: + return "private" + if mode in ("private", "public", "public-as-signer"): + return mode + raise ConfigurationError( + f"Aleo xReserve mode must be 'private', 'public' or 'public-as-signer', got {mode!r}") + + +def _mint_secret(plan: Plan, secret_nonce: str | None) -> str: + """The secret the EVM deposit commits to. A private mint must supply its own. + + ``secret_nonce`` is not a ``Plan`` field and the SDK never stores it: a private deposit that + quietly fell back to the ``"0scalar"`` default would commit to a hook nobody can reproduce, + and ``complete`` needs the same value again to mint the record. + """ + if plan.mint_mode == "private": + if not secret_nonce: + raise ConfigurationError( + "a secret_nonce is required for a private xReserve mint: the deposit commits to " + "(recipient, secret_nonce) and complete() needs the same value again — keep it, " + "the SDK never stores it") + return secret_nonce + return secret_nonce or "0scalar" + + +# ── execute ─────────────────────────────────────────────────────────────────── + +def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, proving: str = "delegate", + mode: str | None = None, record: str | None = None, merkle_proof: str | None = None, + gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, + poll_seconds: float = 1.0, timeout_seconds: float = 120.0) -> Progress: + """Commit funds on the source chain and return the transfer's ``Progress``. + + Runs every source-chain leg for ``plan`` — approval(s) → deposit / dispatch / burn — emitting + a ``Checkpoint`` at each boundary (after each approval hash; after proving and BEFORE + broadcast for Aleo legs; after broadcast). Aleo legs prove with ``proving="delegate"`` (DPS) + or ``"local"``; ``mode`` is ``"caller"|"signer"`` for Aleo Hyperlane and ``"private"|"public"| + "public-as-signer"`` for Aleo xReserve burns (``record`` / ``merkle_proof`` feed a private + burn). The Hyperlane hook payment is re-quoted right before proving unless + ``gas_payment_microcredits`` pins it. ``secret_nonce`` is the private-mint commitment secret + for an EVM xReserve deposit — required when ``plan.mint_mode == "private"``; keep it, + ``complete`` needs it again and the SDK never stores it. + + EVM and Solana legs are dispatched through the module's own ``plan=`` surface, so the route, + registry version, sender and every plan field are re-validated by the module that builds the + transaction. Returns after broadcast; call ``wait`` to observe acceptance and delivery. Once + the irreversible step is broadcast, recover from the checkpoint — never re-run ``execute``. + """ + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + family = resolved.source_chain.family + emit = _Emitter(bridge, plan, on_checkpoint) + + if plan.protocol == "hyperlane" and family == "evm": + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family=family) + call = eth.transfer_remote(plan=plan) + return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + + if plan.protocol == "hyperlane" and family == "solana": + sol = _module(bridge, "sol") + _assert_sender(plan, bridge.solana.address, family=family) + call = sol.transfer_remote(plan=plan) + return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + + if plan.protocol == "hyperlane" and family == "aleo": + as_signer = _aleo_hyperlane_mode(mode) + verification = _delivery_verification(bridge, plan, resolved) + gas = gas_payment_microcredits + if gas is None: + gas = bridge.hyperlane.quote_gas_payment(plan.source_asset_id).payment_microcredits + call = bridge.hyperlane.transfer_remote(plan.source_asset_id, plan.recipient, + amount_atomic=plan.amount_atomic, as_signer=as_signer, + gas_payment_microcredits=gas) + return to_progress(plan, _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, + extra_state=verification)) + + if plan.protocol == "xreserve" and family == "evm": + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family=family) + nonce = _mint_secret(plan, secret_nonce) + call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) + return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + + if plan.protocol == "xreserve" and family == "aleo": + burn_mode = _xreserve_burn_mode(mode) + call = bridge.xreserve.burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, + record=record, merkle_proof=merkle_proof) + return to_progress(plan, _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state={})) + + raise UnsupportedRouteError(f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") + + +__all__ = ["MINT_MODES", "ResolvedRoute", "execute", "prepare", "quote", "resolve_route"] diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 50a7802d..06655cec 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -20,7 +20,9 @@ from aleo import AleoNetworkError from aleo.facade.errors import TransactionNotFound -from aleo_bridge.errors import AttestationError, ConfigurationError, RegistryVersionMismatchError +from aleo_bridge.checkpoint import Checkpoint, create_checkpoint +from aleo_bridge.errors import (AttestationError, ConfigurationError, InvalidRecipientError, + RegistryVersionMismatchError) from aleo_bridge.registry import DEFAULT_REGISTRY from aleo_bridge.types import (Attestation, BridgeStatus, BurnReceipt, ChainStatus, DepositReceipt, DispatchReceipt, EvmHyperlaneQuote, EvmXReserveQuote, GasQuote, @@ -74,17 +76,32 @@ def delegate(self, account=None, **kw): class FakeEvmCall: - def __init__(self, fake: "FakeBridge", intermediates: list[Receipt], final: Any) -> None: + """Mirrors ``EvmCall``/``SolCall``: every broadcast boundary — each approval and the final + transaction — is reduced to a ``Checkpoint``, handed to ``on_checkpoint`` and only then saved + to the bound store, exactly like the real calls' own channel. Without a plan (the non-plan + call form) there is nothing to reduce against, so the raw receipt is passed through instead. + """ + + def __init__(self, fake: "FakeBridge", intermediates: list[Receipt], final: Any, *, + plan: Any = None, store: Any = None) -> None: self.fake, self.intermediates, self.final = fake, intermediates, final + self.plan, self.store = plan, store def build(self) -> list[dict]: return [{"to": "0xrouter", "data": "0x", "value": 0}] + def _emit(self, receipt: Receipt, on_checkpoint) -> None: + payload = receipt if self.plan is None else create_checkpoint(self.plan, receipt, DEFAULT_REGISTRY) + if on_checkpoint is not None: + on_checkpoint(payload) # the caller's channel first: the tx is already on the wire + if self.store is not None and isinstance(payload, Checkpoint): + self.store.save(payload) + def send(self, *, wait=True, timeout_seconds=120.0, poll_seconds=1.0, on_checkpoint=None): self.fake.events.append(("evm_send", timeout_seconds, poll_seconds)) for receipt in self.intermediates: - if on_checkpoint is not None: - on_checkpoint(receipt) + self._emit(receipt, on_checkpoint) + self._emit(self.final.receipt, on_checkpoint) return self.final @@ -200,6 +217,13 @@ class FakeEth: checked against ``DEFAULT_REGISTRY.version``, and the returned quote carries that same ``plan`` object (``.plan is plan``) — ``lifecycle.quote`` is what canonicalizes the plan on the result, so the fake does not need to rebuild one the way the real module does. + + The write side mirrors the same signatures: ``transfer_remote(asset=None, recipient=None, *, + amount=None, amount_atomic=None, plan=None)`` and ``deposit_usdc(recipient=None, *, amount=None, + amount_atomic=None, mint_mode=None, secret_nonce="0scalar", plan=None)``, with the real module's + guards — ``plan`` plus ``asset`` is a ``ValueError``, no plan and no recipient is an + ``InvalidRecipientError``, a stale plan is a ``RegistryVersionMismatchError``, and a plan whose + sender is not the connected account is a ``ConfigurationError``. """ def __init__(self, fake: "FakeBridge", address: str) -> None: @@ -231,15 +255,36 @@ def quote_transfer_remote(self, asset=None, recipient=None, *, amount=None, amou recipient_bytes32=b"\x00" * 32, native_value_atomic=(amount_atomic or 0) + 1000, native_fee_atomic=1000, approval_required=self.approval_required) - def transfer_remote(self, asset, recipient, *, amount=None, amount_atomic=None) -> FakeEvmCall: - self.fake.calls.append(("eth.transfer_remote", dict(asset=asset, recipient=recipient, - amount_atomic=amount_atomic))) - route_id = f"hyperlane:{asset}->aleo/{asset.split('/')[1]}" + def _check_plan(self, plan) -> None: + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + if plan.sender and plan.sender.lower() != self.address.lower(): + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {self.address}") + + def transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_atomic=None, + plan=None) -> FakeEvmCall: + if plan is not None: + if asset is not None: + raise ValueError("Pass plan= or asset=, not both") + self._check_plan(plan) + self.fake.calls.append(("eth.transfer_remote", {"plan": plan})) + route_id, recipient, amount_atomic = plan.route_id, plan.recipient, plan.amount_atomic + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + if asset is None: + raise ValueError("transfer_remote needs asset= or plan=") + self.fake.calls.append(("eth.transfer_remote", dict(asset=asset, recipient=recipient, + amount_atomic=amount_atomic))) + route_id = f"hyperlane:{asset}->aleo/{asset.split('/')[1]}" tx = "0x" + "aa" * 32 receipt = Receipt(id=tx, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=tx, protocol_state={"routeId": route_id, "approvalTxIds": [], "sourceSender": self.address, "amountAtomic": str(amount_atomic or 0)}) - return FakeEvmCall(self.fake, self.intermediates, DispatchReceipt(tx, route_id, None, amount_atomic or 0, receipt)) + return FakeEvmCall(self.fake, self.intermediates, + DispatchReceipt(tx, route_id, None, amount_atomic or 0, receipt), + plan=plan, store=self.fake.checkpoints) def quote_deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, secret_nonce="0scalar", sender=None, route=None, plan=None): @@ -263,12 +308,22 @@ def quote_deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, allowance_atomic=0 if self.approval_required else 10_000_000, approval_required=self.approval_required, max_fee_atomic=100_000) - def deposit_usdc(self, recipient, *, amount=None, amount_atomic=None, mint_mode="public", - secret_nonce="0scalar") -> FakeEvmCall: - self.fake.calls.append(("eth.deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, - mint_mode=mint_mode, secret_nonce=secret_nonce))) - route_id = ("xreserve:ethereum/usdc->aleo/usdcx" if self.fake.environment == "mainnet" - else "xreserve:sepolia/usdc->aleo-testnet/usdcx") + def deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, + secret_nonce="0scalar", plan=None) -> FakeEvmCall: + if plan is not None: + self._check_plan(plan) + self.fake.calls.append(("eth.deposit_usdc", {"plan": plan, "secret_nonce": secret_nonce})) + recipient, amount_atomic = plan.recipient, plan.amount_atomic + mint_mode = plan.mint_mode if mint_mode is None else mint_mode + route_id = plan.route_id + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + mint_mode = "public" if mint_mode is None else mint_mode + self.fake.calls.append(("eth.deposit_usdc", dict(recipient=recipient, amount_atomic=amount_atomic, + mint_mode=mint_mode, secret_nonce=secret_nonce))) + route_id = ("xreserve:ethereum/usdc->aleo/usdcx" if self.fake.environment == "mainnet" + else "xreserve:sepolia/usdc->aleo-testnet/usdcx") tx = "0x" + "bb" * 32 message_hash = "0x" + "cc" * 32 receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, source_tx_id=tx, @@ -278,7 +333,8 @@ def deposit_usdc(self, recipient, *, amount=None, amount_atomic=None, mint_mode= "payload": "0x" + "ee" * 305, "messageHash": message_hash, "bridgeProgram": "usdcx_bridge_v2.aleo"}) return FakeEvmCall(self.fake, self.intermediates, - DepositReceipt(tx, route_id, message_hash, "0x" + "dd" * 32, receipt)) + DepositReceipt(tx, route_id, message_hash, "0x" + "dd" * 32, receipt), + plan=plan, store=self.fake.checkpoints) def balance(self, asset) -> int: self.fake.calls.append(("eth.balance", asset)) @@ -302,12 +358,16 @@ def recover_source(self, plan, checkpoint, *, required=False) -> Receipt: class FakeSol: """Mirrors ``aleo_bridge.sol.SolModule``'s public surface for lifecycle tests. - ``quote_transfer_remote`` mirrors the REAL ``SolModule.quote_transfer_remote`` signature - exactly: ``recipient`` is required positionally (no default, unlike ``EthModule``'s quote - methods) and, when ``plan=`` is given, the real module silently overwrites - recipient/amount/amount_atomic/sender from the plan rather than raising ``ValueError`` on a - conflict — there is no ``asset=``/``route=`` kwarg to conflict with in the first place. This - fake matches that real behavior rather than the more Eth-like ``ValueError`` ruling. + ``quote_transfer_remote(recipient=None, *, amount=None, amount_atomic=None, sender=None, + plan=None)`` and ``transfer_remote(recipient=None, *, amount=None, amount_atomic=None, + plan=None)`` mirror the real ``SolModule`` signatures: ``plan=`` alone is enough (it supplies + recipient, amount and sender), ``plan`` together with ``sender=`` is a ``ValueError``, and + neither a plan nor a recipient is an ``InvalidRecipientError``. There is no ``asset=``/``route=`` + kwarg to conflict with, so a plan overrides recipient/amount silently — the real module's + behavior, not the Eth-like ``ValueError`` on every conflict. + + Solana addresses are base58 and therefore case-SENSITIVE: the plan-sender check compares them + exactly, unlike ``FakeEth``'s case-insensitive EVM comparison. """ def __init__(self, fake: "FakeBridge", address: str) -> None: @@ -316,30 +376,48 @@ def __init__(self, fake: "FakeBridge", address: str) -> None: self.source_status_result: Receipt | None = None self.intermediates: list[Receipt] = [] - def quote_transfer_remote(self, recipient, *, amount=None, amount_atomic=None, sender=None, plan=None): + def _check_plan(self, plan) -> None: + if plan.registry_version != DEFAULT_REGISTRY.version: + raise RegistryVersionMismatchError( + f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + if plan.sender and plan.sender != self.address: # base58: compared exactly + raise ConfigurationError(f"Prepared sender {plan.sender} does not match connected account {self.address}") + + def quote_transfer_remote(self, recipient=None, *, amount=None, amount_atomic=None, sender=None, plan=None): if plan is not None: - if plan.registry_version != DEFAULT_REGISTRY.version: - raise RegistryVersionMismatchError( - f"Plan uses registry {plan.registry_version}; this client has {DEFAULT_REGISTRY.version}") + if sender is not None: + raise ValueError("Pass plan= or sender=, not both: the plan carries its own sender") + self._check_plan(plan) self.fake.calls.append(("sol.quote_transfer_remote", {"plan": plan})) return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=plan, fees=(), amount_out=None, igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, total_lamports=plan.amount_atomic + 7_914_240, unique_message_address="uniq1111111111111111111111111111111111111111") + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") self.fake.calls.append(("sol.quote_transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) return SolanaHyperlaneQuote(kind="solana-hyperlane", plan=None, fees=(), amount_out=None, igp_lamports=2_900_000, network_fee_lamports=10_000, rent_lamports=5_004_240, total_lamports=(amount_atomic or 0) + 7_914_240, unique_message_address="uniq1111111111111111111111111111111111111111") - def transfer_remote(self, recipient, *, amount=None, amount_atomic=None) -> FakeSolCall: - self.fake.calls.append(("sol.transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) + def transfer_remote(self, recipient=None, *, amount=None, amount_atomic=None, plan=None) -> FakeSolCall: + if plan is not None: + self._check_plan(plan) + self.fake.calls.append(("sol.transfer_remote", {"plan": plan})) + recipient, amount_atomic = plan.recipient, plan.amount_atomic + else: + if recipient is None: + raise InvalidRecipientError("recipient is required when no plan is given") + self.fake.calls.append(("sol.transfer_remote", dict(recipient=recipient, amount_atomic=amount_atomic))) route_id = "hyperlane:solana/sol->aleo/sol" sig = "5igNature" * 8 receipt = Receipt(id=sig, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id=sig, protocol_state={"routeId": route_id, "signature": sig, "blockhash": "recent", "lastValidBlockHeight": "123456789"}) - return FakeSolCall(self.fake, self.intermediates, DispatchReceipt(sig, route_id, None, amount_atomic or 0, receipt)) + return FakeSolCall(self.fake, self.intermediates, + DispatchReceipt(sig, route_id, None, amount_atomic or 0, receipt), + plan=plan, store=self.fake.checkpoints) def balance(self) -> int: self.fake.calls.append(("sol.balance",)) diff --git a/bridge-sdk/tests/test_execute.py b/bridge-sdk/tests/test_execute.py new file mode 100644 index 00000000..441aae43 --- /dev/null +++ b/bridge-sdk/tests/test_execute.py @@ -0,0 +1,244 @@ +"""``lifecycle.execute`` — the verb that commits funds on the source chain. + +The invariants under test: every source leg is dispatched through the module's own ``plan=`` +surface (never a re-derived asset/recipient/amount), an Aleo leg proves → checkpoints the exact +transaction → broadcasts → checkpoints the id (so a crash between proving and broadcast is +recoverable), and every boundary reaches the caller's callback once and the bound store once. +""" +import dataclasses +import json + +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import ConfigurationError, RouteUnavailableError +from aleo_bridge.lifecycle import execute, prepare +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +PREPARED_TX = {"preparedTransaction": {"transactionId": "at1fake1", + "serializedTransaction": json.dumps( + {"type": "execute", "id": "at1fake1", "fee": {}})}} + + +def _aleo_eth_plan(b, recipient=EVM_ADDRESS): + return prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=recipient) + + +def _usdc_plan(b, **kw): + return prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, **kw) + + +def _wbtc_plan(b, **kw): + return prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, **kw) + + +def _sol_plan(b, **kw): + return prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, **kw) + + +# ── Aleo-origin legs ────────────────────────────────────────────────────────── + +def test_aleo_hyperlane_checkpoints_prepared_tx_before_broadcast_and_requotes_igp(): + b = FakeBridge(ethereum=False) + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append) + + assert b.calls[0] == ("hyperlane.quote_gas_payment", "aleo/eth") # re-quoted at the last moment + assert b.calls[1][1]["gas_payment_microcredits"] == 8_174_147 + assert b.calls[1][1]["as_signer"] is False and b.calls[1][1]["amount_atomic"] == 1 + kinds = [e[0] for e in b.events] + assert kinds == ["delegate_prepared", "checkpoint:SOURCE_SUBMISSION_PENDING", "submit", + "checkpoint:SOURCE_CONFIRMING"] + assert b.events[2][2] is False # submit_prepared(wait=False) + assert cps[0].source == PREPARED_TX + assert cps[1].source == {"transactionId": "at1fake1"} + assert "deliveryVerification" not in cps[0].to_dict() # no ETH connection → no baseline + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + assert progress.receipt.source_tx_id == "at1fake1" + assert progress.receipt.protocol_state["routeId"] == plan.route_id + + +def test_aleo_hyperlane_pinned_gas_and_signer_mode_and_local_proving(): + b = FakeBridge(ethereum=False) + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, mode="signer", proving="local") + assert b.calls[0][0] == "hyperlane.transfer_remote" # no quote call + assert b.calls[0][1]["gas_payment_microcredits"] == 1 and b.calls[0][1]["as_signer"] is True + assert [e[0] for e in b.events][0] == "prove" + with pytest.raises(ConfigurationError, match="proving"): + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, proving="wallet") + with pytest.raises(ConfigurationError, match="mode"): + execute(b, _aleo_eth_plan(b), gas_payment_microcredits=1, mode="private") + + +def test_aleo_hyperlane_captures_destination_balance_baseline_for_own_recipient(): + b = FakeBridge() # ethereum configured, address == recipient + b.eth.balances["ethereum/eth"] = 100 + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, gas_payment_microcredits=1, on_checkpoint=cps.append) + # read before the dispatch is even built, so the pre-broadcast checkpoint can carry it + assert b.calls[0] == ("eth.balance", "ethereum/eth") + assert b.calls[1][0] == "hyperlane.transfer_remote" + assert cps[0].delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + assert progress.receipt.protocol_state["destinationBalanceBeforeAtomic"] == "100" + assert progress.receipt.protocol_state["expectedDestinationIncreaseAtomic"] == "1" + # a recipient that is not our connection's address gets no baseline (we cannot read its balance) + b2 = FakeBridge() + cps2 = [] + execute(b2, _aleo_eth_plan(b2, recipient="0x0000000000000000000000000000000000000002"), + gas_payment_microcredits=1, on_checkpoint=cps2.append) + assert cps2[0].delivery_verification is None and ("eth.balance", "ethereum/eth") not in b2.calls + + +def test_aleo_xreserve_burn_modes_and_private_inputs(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.5", + recipient=EVM_ADDRESS) + cps = [] + progress = execute(b, plan, record="{ owner: aleo1..., amount: 3000000u128.private }", + merkle_proof="[{ siblings: [...], leaf_index: 1u32 }, { ... }]", on_checkpoint=cps.append) + assert b.calls[0][0] == "xreserve.burn" + assert b.calls[0][1]["mode"] == "private" and b.calls[0][1]["amount_atomic"] == 2_500_000 + assert b.calls[0][1]["record"].startswith("{ owner") and b.calls[0][1]["merkle_proof"].startswith("[") + assert [c.source for c in cps] == [PREPARED_TX, {"transactionId": "at1fake1"}] + assert progress.receipt.protocol_state["burnMode"] == "private" + b2 = FakeBridge(ethereum=False) + execute(b2, plan, mode="public-as-signer") + assert b2.calls[0][1]["mode"] == "public-as-signer" + with pytest.raises(ConfigurationError, match="mode"): + execute(b2, plan, mode="signer") + + +# ── EVM- and Solana-origin legs ─────────────────────────────────────────────── + +def test_evm_hyperlane_forwards_intermediate_checkpoints_and_polling_controls(): + b = FakeBridge() + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + approval = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM_ADDRESS}) + b.eth.intermediates = [approval] + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append, poll_seconds=2.0, timeout_seconds=300.0) + assert b.calls == [("eth.transfer_remote", {"plan": plan})] # the module re-derives nothing + assert ("evm_send", 300.0, 2.0) in b.events + assert [c.source for c in cps] == [{"approvalTransactionIds": ["0x" + "11" * 32]}, + {"transactionId": "0x" + "aa" * 32}] + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + + +def test_evm_xreserve_passes_mint_mode_and_secret_nonce(): + b = FakeBridge() + plan = _usdc_plan(b, mint_mode="private") + progress = execute(b, plan, secret_nonce="7scalar") + assert b.calls[0] == ("eth.deposit_usdc", {"plan": plan, "secret_nonce": "7scalar"}) + assert progress.receipt.status is Status.ATTESTATION_PENDING and progress.next == "wait" + assert progress.receipt.protocol_state["mintMode"] == "private" + assert "secretNonce" not in json.dumps(progress.receipt.protocol_state) + assert "7scalar" not in json.dumps(progress.receipt.protocol_state) + + +def test_a_private_mint_without_a_secret_nonce_is_refused_before_any_rpc(): + """``secret_nonce`` is not a plan field and the SDK never stores it: a private deposit that + silently fell back to "0scalar" would mint to a commitment the caller cannot ever reproduce.""" + b = FakeBridge() + with pytest.raises(ConfigurationError, match="secret_nonce"): + execute(b, _usdc_plan(b, mint_mode="private")) + assert b.calls == [] + execute(b, _usdc_plan(b)) # public mint: the default is fine + assert b.calls[0][1]["secret_nonce"] == "0scalar" + b2 = FakeBridge() + execute(b2, _usdc_plan(b2, mint_mode="record")) + assert b2.calls[0][1]["secret_nonce"] == "0scalar" + + +def test_solana_hyperlane(): + b = FakeBridge(solana=True) + plan = _sol_plan(b, sender=SOL_ADDRESS) + cps = [] + progress = execute(b, plan, on_checkpoint=cps.append) + assert b.calls == [("sol.transfer_remote", {"plan": plan})] + assert cps[-1].source["blockhash"] == "recent" and cps[-1].source["lastValidBlockHeight"] == "123456789" + assert progress.receipt.status is Status.SOURCE_CONFIRMING + + +# ── refusals ────────────────────────────────────────────────────────────────── + +def test_sender_mismatch_missing_connection_and_unavailable_route(): + b = FakeBridge() + with pytest.raises(ConfigurationError, match="sender"): + execute(b, _wbtc_plan(b, sender="0x0000000000000000000000000000000000000009")) + with pytest.raises(ConfigurationError, match="Solana connection"): + execute(b, _sol_plan(b)) + with pytest.raises(RouteUnavailableError): + execute(b, prepare(b.registry, source="aleo/aleo", destination="ethereum/aleo", amount="1", + recipient=EVM_ADDRESS)) + no_eth = FakeBridge(ethereum=False) # bridge.ethereum is None → bridge.eth must not be touched + with pytest.raises(ConfigurationError, match="Ethereum connection"): + execute(no_eth, _wbtc_plan(no_eth)) + assert b.calls == [] and no_eth.calls == [] + + +def test_the_sender_check_is_case_insensitive_for_evm_and_exact_for_solana(): + """EVM addresses are hex (checksum casing is cosmetic); Solana addresses are base58, where a + case change is a different account entirely.""" + b = FakeBridge() + b.ethereum.address = b.eth.address = "0xAbC0000000000000000000000000000000000001" + execute(b, _wbtc_plan(b, sender="0xabc0000000000000000000000000000000000001")) + assert b.calls[0][0] == "eth.transfer_remote" + + mixed = "So11111111111111111111111111111111111111112" + s = FakeBridge(solana=True) + s.solana.address = s.sol.address = mixed + with pytest.raises(ConfigurationError, match="sender"): + execute(s, _sol_plan(s, sender=mixed.lower())) + assert s.calls == [] + execute(s, _sol_plan(s, sender=mixed)) + assert s.calls[0][0] == "sol.transfer_remote" + + +# ── the bound checkpoint store ──────────────────────────────────────────────── + +def test_bound_store_saves_every_checkpoint_and_replaces_superseded_ids(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(checkpoints=store) + plan = _usdc_plan(b) + approval = Receipt(id="0x" + "11" * 32, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + b.eth.intermediates = [approval] + execute(b, plan) + saved = store.list() + assert [c.id for c in saved] == ["0x" + "cc" * 32] # approval file replaced by the deposit's + assert saved[0].source == {"transactionId": "0x" + "bb" * 32, "hookData": "0x" + "00" * 65} + + +def test_module_and_lifecycle_checkpoint_channels_do_not_double_write(tmp_path): + """The module saves the checkpoints it emits and ``execute`` emits the final receipt again: + the caller still sees each boundary once and the store ends with one record per transfer.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(checkpoints=store) + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + b.eth.intermediates = [Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]})] + cps = [] + execute(b, plan, on_checkpoint=cps.append) + assert [c.id for c in cps] == ["0x" + "11" * 32, "0x" + "aa" * 32] # no repeated boundary + saved = store.list() + assert [c.id for c in saved] == ["0x" + "aa" * 32] + assert store.load("0x" + "aa" * 32).source == {"transactionId": "0x" + "aa" * 32} + + +def test_a_stale_plan_is_refused_before_anything_is_sent(): + b = FakeBridge() + plan = dataclasses.replace(_wbtc_plan(b, sender=EVM_ADDRESS), registry_version="0000-00-00.stale") + with pytest.raises(Exception, match="registry"): + execute(b, plan) + assert b.calls == [] and b.events == [] From fa8553dd0d267eb4034a30e8ed387e0fa98bd405 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:48:19 -0400 Subject: [PATCH 71/94] fix(bridge-sdk): arm the single-use guard on an echoed-hash mismatch; adapter close survives a raising client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/python/aleo_bridge/eth.py | 13 ++++++++- bridge-sdk/python/aleo_bridge/sol.py | 29 ++++++++++++------- bridge-sdk/tests/test_evm_call.py | 16 +++++++++++ bridge-sdk/tests/test_sol_connection.py | 38 +++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/eth.py b/bridge-sdk/python/aleo_bridge/eth.py index 9be39dc4..a5df0962 100644 --- a/bridge-sdk/python/aleo_bridge/eth.py +++ b/bridge-sdk/python/aleo_bridge/eth.py @@ -168,6 +168,12 @@ def send_transaction(self, tx: dict) -> str: tx.setdefault("chainId", self.chain_id) tx.setdefault("value", 0) if self._signer is None: + # Default-account mode: the caller's middleware signs, so the hash only exists once the node + # answers. There is nothing to capture beforehand and nothing to compare the answer against, + # so the ambiguous-send protections below (lost response, echo mismatch, EvmCall's single-use + # guard) cannot apply here — a failed send leaves the caller unable to tell whether the + # transaction is in the mempool. Prefer a local signer (private_key=/signer=) for anything + # that moves funds. return Web3.to_hex(self._w3.eth.send_transaction(tx)) tx.setdefault("nonce", self._w3.eth.get_transaction_count(sender, "pending")) if "gas" not in tx: @@ -196,10 +202,15 @@ def send_transaction(self, tx: dict) -> str: error.broadcast_id = local_hash # type: ignore[attr-defined] raise error from exc if echoed.lower() != local_hash.lower(): - raise BridgeError( + error = BridgeError( f"Ethereum RPC echoed transaction hash {echoed} for a transaction signed as {local_hash}; " "refusing to checkpoint or follow the wrong hash — check bridge.eth.source_status / the " f"explorer for {local_hash} before retrying") + # The node ANSWERED, so it took the bytes: they may sit in its mempool under local_hash even + # though it echoed something else. That is the same ambiguity as a lost response, so arm + # EvmCall's single-use guard here too rather than letting a retry sign a second transfer. + error.broadcast_id = local_hash # type: ignore[attr-defined] + raise error return local_hash def wait_for_receipt(self, tx_hash: str, *, timeout_seconds: float, poll_seconds: float) -> dict | None: diff --git a/bridge-sdk/python/aleo_bridge/sol.py b/bridge-sdk/python/aleo_bridge/sol.py index b4804dff..eb4717be 100644 --- a/bridge-sdk/python/aleo_bridge/sol.py +++ b/bridge-sdk/python/aleo_bridge/sol.py @@ -300,26 +300,33 @@ def __init__(self, client: Any) -> None: self._thread.start() self._closed = False - def _run(self, coroutine: Any) -> Any: - return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() + def _run(self, coroutine: Any, timeout: float | None = None) -> Any: + return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result(timeout) def close(self, timeout: float = 5.0) -> None: """Close the wrapped client, then stop the private loop thread. Idempotent. The wrapped client's own ``close()`` runs FIRST and on our loop: solana-py's ``AsyncClient`` owns an aiohttp session that can only be closed from the loop it was created on, so stopping - the thread first would leak the connection pool. The loop itself is closed only once the - thread has actually exited — closing a running loop raises. + the thread first would leak the connection pool. It is bounded by *timeout* and its failure is + swallowed — a third-party transport that cannot close must not leave our loop thread running + forever — and the loop is stopped either way. ``_closed`` is set only once that stop has been + issued, so a raising ``close()`` cannot turn the next call into a no-op over a live thread. + The loop itself is closed only after the thread has actually exited; closing a running loop raises. """ if self._closed: return - self._closed = True - closer = getattr(self._client, "close", None) - if callable(closer): - result = closer() - if inspect.isawaitable(result): - self._run(result) - self._loop.call_soon_threadsafe(self._loop.stop) + try: + closer = getattr(self._client, "close", None) + if callable(closer): + result = closer() + if inspect.isawaitable(result): + self._run(result, timeout=timeout) + except Exception: # noqa: BLE001 — including a _run timeout + pass # best-effort release; the thread still has to stop + finally: + self._loop.call_soon_threadsafe(self._loop.stop) + self._closed = True self._thread.join(timeout=timeout) if self._thread.is_alive(): return # still running: leave the loop alone rather than raise diff --git a/bridge-sdk/tests/test_evm_call.py b/bridge-sdk/tests/test_evm_call.py index 4cf2c341..9dbb6701 100644 --- a/bridge-sdk/tests/test_evm_call.py +++ b/bridge-sdk/tests/test_evm_call.py @@ -358,6 +358,22 @@ def test_a_mismatched_echoed_hash_stops_the_call_before_any_checkpoint(): assert "eth_getTransactionReceipt" not in w3.provider.methods +def test_a_mismatched_echoed_hash_also_arms_the_single_use_guard(): + """The node answered, so the signed bytes may be in its mempool under the hash we computed — + the same ambiguity as a lost response, and the same reason not to resend.""" + w3 = fake_web3() + w3.provider.echo_hashes[1] = "0x" + "ab" * 32 + call, _ = make_call(w3, approvals=0) + with pytest.raises(BridgeError, match="echoed transaction hash"): + call.send(poll_seconds=0.001) + local_hash = w3.provider.hash_at(1) + with pytest.raises(BridgeError) as exc: + call.send(poll_seconds=0.001) + assert str(exc.value) == (f"this call already broadcast {local_hash}; use bridge.eth.source_status(plan, receipt) " + "to follow it — do not resend") + assert w3.provider.methods.count("eth_sendRawTransaction") == 1 + + def test_bound_store_saves_every_checkpoint(tmp_path): w3 = fake_web3() store = FileCheckpointStore(tmp_path) diff --git a/bridge-sdk/tests/test_sol_connection.py b/bridge-sdk/tests/test_sol_connection.py index 412b52c9..ae503ff5 100644 --- a/bridge-sdk/tests/test_sol_connection.py +++ b/bridge-sdk/tests/test_sol_connection.py @@ -234,6 +234,44 @@ async def close(self): adapter.close() # idempotent: closes the client exactly once +def test_adapter_close_still_stops_the_thread_when_the_wrapped_client_close_raises(): + """A third-party client that fails to close must not leak our private event-loop thread.""" + pytest.importorskip("solders") + + class ExplodingClient: + def __init__(self): + self.attempts = 0 + + async def get_balance(self, pubkey, commitment=None): # pragma: no cover - never called + return sol.RpcResult(7) + + def close(self): + self.attempts += 1 + raise OSError("socket already gone") + + fake = ExplodingClient() + adapter = sol._AsyncClientAdapter(fake) + adapter.close() + assert adapter._thread.is_alive() is False + adapter.close() # idempotent: no second close attempt + assert fake.attempts == 1 + + +def test_adapter_close_still_stops_the_thread_when_an_async_client_close_raises(): + pytest.importorskip("solders") + + class ExplodingAsyncClient: + async def get_balance(self, pubkey, commitment=None): # pragma: no cover - never called + return sol.RpcResult(7) + + async def close(self): + raise OSError("session already detached") + + adapter = sol._AsyncClientAdapter(ExplodingAsyncClient()) + adapter.close() + assert adapter._thread.is_alive() is False + + def test_solana_exit_never_raises_even_when_the_client_close_fails(): pytest.importorskip("solders") From 5b7e954cae37c39be514183a6c7b50d5371b71a9 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 19:58:40 -0400 Subject: [PATCH 72/94] feat(bridge-sdk): get_status() branch table incl. nullifier-first inbound xReserve and balance-diff fallback --- bridge-sdk/python/aleo_bridge/lifecycle.py | 212 ++++++++++++++++++- bridge-sdk/tests/fakes/fake_bridge.py | 8 + bridge-sdk/tests/test_get_status.py | 225 +++++++++++++++++++++ 3 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 bridge-sdk/tests/test_get_status.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 3d3ed4f3..37289c09 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -17,11 +17,13 @@ from dataclasses import dataclass, replace from typing import Any, Callable +from . import _sealevel from ._plan import build_plan from .checkpoint import Checkpoint, create_checkpoint from .errors import ( CheckpointInvalidError, ConfigurationError, + DeliveryUnknownError, InvalidAmountError, InvalidRecipientError, RegistryVersionMismatchError, @@ -455,4 +457,212 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin raise UnsupportedRouteError(f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") -__all__ = ["MINT_MODES", "ResolvedRoute", "execute", "prepare", "quote", "resolve_route"] +# ── Aleo transaction status ─────────────────────────────────────────────────── + +def aleo_transaction_status(bridge, tx_id: str) -> tuple[str, str | None]: + """``("accepted" | "rejected" | "pending", error)`` from the confirmed-transaction envelope. + + Reads ``GET /transaction/confirmed/{id}`` through the facade. A 404 + (``TransactionNotFound``) means not confirmed yet → ``pending``. The + envelope's top-level ``status`` is the node's verdict; it carries no reason, + so the error text is generic. + """ + from aleo.facade.errors import TransactionNotFound + try: + confirmed = bridge.aleo.network.get_confirmed_transaction(tx_id) + except TransactionNotFound: + return "pending", None + status = confirmed.get("status") if isinstance(confirmed, dict) else getattr(confirmed, "status", None) + if status == "accepted": + return "accepted", None + if status == "rejected": + return "rejected", f"Aleo transaction {tx_id} was rejected by the network" + return "pending", None + + +_HEX = re.compile(r"^0x[0-9a-fA-F]*$") +_MESSAGE_ID_RE = re.compile(r"^0x[0-9a-fA-F]{64}$") + + +def _hex_bytes(value: Any, *, length: int | None = None) -> bytes | None: + """Strict ``0x`` hex → bytes, or None when malformed / wrong length.""" + if not isinstance(value, str) or not _HEX.match(value) or len(value) % 2: + return None + data = bytes.fromhex(value[2:]) + if length is not None and len(data) != length: + return None + return data + + +def _check_receipt(plan: Plan, receipt: Receipt) -> None: + if receipt.protocol != plan.protocol or receipt.protocol_state.get("routeId") != plan.route_id: + raise CheckpointInvalidError("Bridge receipt does not match the prepared route") + + +def _clear_action(receipt: Receipt, **changes) -> Receipt: + return receipt.replace(next_action=None, **changes) + + +def _message_id(receipt: Receipt) -> str | None: + """``protocol_state["messageId"]`` first; else ``receipt.id`` when it is itself a message id. + + Solana and EVM Hyperlane receipts carry the id in ``protocol_state["messageId"]`` once + known; a receipt that instead carries the message id AS its own ``id`` (some Aleo-origin + shapes) falls back to that, but only when it is an exact 32-byte ``0x`` hex string — never a + signature or an unrelated transaction hash of a different width. + """ + state = receipt.protocol_state + message_id = state.get("messageId") + if isinstance(message_id, str) and message_id: + return message_id + if isinstance(receipt.id, str) and _MESSAGE_ID_RE.fullmatch(receipt.id): + return receipt.id + return None + + +# ── get_status ──────────────────────────────────────────────────────────────── + +def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: + """One refresh of the transfer's state — no polling, no signing. + + Ports veil's branch table in order: terminal receipts (``COMPLETED``/``FAILED``/``EXPIRED``) + return untouched; EVM approvals and source confirmations delegate to the chain module (never + called for any other status — both ``EthModule.source_status`` and ``SolModule.source_status`` + raise otherwise); Aleo source acceptance moves to ``DELIVERY_PENDING``; Hyperlane delivery is + read from the destination Mailbox by message id (filling a missing Solana message id from the + source transaction's logs first, never handing a signature to ``is_delivered``), or from the + destination balance baseline for Aleo-origin routes; inbound xReserve reads the destination + nullifier FIRST (invariant 6), then Circle's attestation (private mode stops at + ``DESTINATION_ACTION_REQUIRED`` with ``next_action`` = ``{"kind": "xreserve-private-mint", + "chainId": ...}``), then the private mint's acceptance. Returns the SAME object when nothing + changed. + + A Solana transport failure inside ``SolModule.source_status`` (or the message-id log read) is + not swallowed here: a single refresh may raise on a flaky public RPC, and retrying with backoff + is ``wait``'s job, not this function's. + """ + resolved = resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + if receipt.status in TERMINAL: + return receipt + route, src, dst = resolved.route, resolved.source_chain, resolved.destination_chain + state = receipt.protocol_state + + # 1. EVM approval → wallet boundary + if receipt.status is Status.SOURCE_APPROVAL_PENDING and src.family == "evm": + return _module(bridge, "eth").source_status(plan, receipt) + + # 2. Aleo source acceptance is the irreversible boundary + if receipt.status is Status.SOURCE_CONFIRMING and src.family == "aleo": + if not receipt.source_tx_id: + raise CheckpointInvalidError("Bridge receipt is missing its Aleo source transaction id") + verdict, error = aleo_transaction_status(bridge, receipt.source_tx_id) + if verdict == "accepted": + return _clear_action(receipt, status=Status.DELIVERY_PENDING) + if verdict == "rejected": + return _clear_action(receipt, status=Status.FAILED, + protocol_state={**state, "sourceError": error}) + return receipt + + # 3/4. Hyperlane source confirmation on EVM / Solana (extracts messageId) + if receipt.status is Status.SOURCE_CONFIRMING and route.protocol == "hyperlane": + if src.family == "evm": + return _module(bridge, "eth").source_status(plan, receipt) + if src.family == "solana": + return _module(bridge, "sol").source_status(plan, receipt) + + # 5. Hyperlane delivery: the destination Mailbox is canonical + message_id = _message_id(receipt) + if (message_id is None and receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" + and src.family == "solana" and state.get("messageIdUnavailable") and receipt.source_tx_id): + try: + logs = _module(bridge, "sol")._transaction_logs(receipt.source_tx_id) + except Exception: # noqa: BLE001 — advisory fill-in only + logs = None + filled = None if logs is None else _sealevel.extract_hyperlane_message_id(logs) + if filled is not None: + new_state = {k: v for k, v in state.items() if k != "messageIdUnavailable"} + new_state["messageId"] = filled + receipt = receipt.replace(id=filled, protocol_state=new_state) + state, message_id = new_state, filled + # else: still unavailable — fall through unchanged; never hand the signature to is_delivered + + if (receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" + and message_id is not None and dst.family in ("aleo", "evm")): + delivered = (bridge.hyperlane.is_delivered(message_id) if dst.family == "aleo" + else _module(bridge, "eth").is_delivered(message_id)) + return _clear_action(receipt, status=Status.COMPLETED) if delivered else receipt + + # 6. Aleo-origin Hyperlane without a message id: destination balance baseline + if receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" and src.family == "aleo": + before, expected = state.get("destinationBalanceBeforeAtomic"), state.get("expectedDestinationIncreaseAtomic") + if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): + return receipt + current = _read_destination_balance(bridge, plan, resolved) + if current is None: + raise DeliveryUnknownError( + f"No destination balance reader is configured for {dst.id}: bind the {dst.id} connection " + "whose address is the recipient, or confirm delivery out of band") + if current < int(before) + int(expected): + return receipt + return _clear_action(receipt, status=Status.COMPLETED) + + # 7. Other Hyperlane states are observed elsewhere + if route.protocol == "hyperlane": + return receipt + + # 8. xReserve Aleo→EVM: Circle exposes no canonical delivery query + if (receipt.status is Status.DELIVERY_PENDING and route.protocol == "xreserve" + and src.family == "aleo" and dst.family == "evm"): + return receipt + + # 9. Everything else that is not inbound xReserve + if route.protocol != "xreserve" or src.family != "evm" or dst.family != "aleo": + raise UnsupportedRouteError("Status refresh is not implemented for this bridge route") + + # 10. xReserve EVM→Aleo — destination nullifier first (invariant 6) + if receipt.status in (Status.ATTESTATION_PENDING, Status.DELIVERY_PENDING, Status.DESTINATION_ACTION_REQUIRED): + nonce = state.get("nonce") + if not isinstance(nonce, str): + payload = _hex_bytes(state.get("payload")) + if payload is not None: + from .encoding import xreserve_nonce_from_payload + nonce = "0x" + xreserve_nonce_from_payload(payload).hex() + if isinstance(nonce, str) and nonce and bridge.xreserve.is_delivered(nonce, route=route): + return _clear_action(receipt, status=Status.COMPLETED) + + if receipt.status is Status.SOURCE_CONFIRMING: + return _module(bridge, "eth").source_status(plan, receipt) + + if receipt.status is Status.ATTESTATION_PENDING: + message_hash = state.get("messageHash") + if _hex_bytes(message_hash, length=32) is None: + raise CheckpointInvalidError("xReserve receipt is missing its Circle message hash") + attestation = bridge.xreserve.get_attestation(message_hash, route=route) + if attestation is None: + return receipt + with_att = {**state, "attestation": "0x" + attestation.attestation.hex()} + if plan.mint_mode != "private": + return receipt.replace(status=Status.DELIVERY_PENDING, protocol_state=with_att) + return receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, protocol_state=with_att, + next_action={"kind": "xreserve-private-mint", "chainId": dst.id}) + + if receipt.status is Status.DESTINATION_ACTION_REQUIRED: + return receipt + + if receipt.status is Status.DESTINATION_CONFIRMING: + if not receipt.destination_tx_id: + raise CheckpointInvalidError("xReserve receipt is missing its Aleo destination transaction id") + verdict, error = aleo_transaction_status(bridge, receipt.destination_tx_id) + if verdict == "accepted": + return _clear_action(receipt, status=Status.COMPLETED) + if verdict == "rejected": + return _clear_action(receipt, status=Status.FAILED, + protocol_state={**state, "destinationError": error}) + return receipt + + return receipt + + +__all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "execute", "get_status", "prepare", + "quote", "resolve_route"] diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 06655cec..2fe3aa48 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -375,6 +375,8 @@ def __init__(self, fake: "FakeBridge", address: str) -> None: self.balance_lamports = 0 self.source_status_result: Receipt | None = None self.intermediates: list[Receipt] = [] + self.transaction_logs_result: list[str] | None = None + self.transaction_logs_error: Exception | None = None def _check_plan(self, plan) -> None: if plan.registry_version != DEFAULT_REGISTRY.version: @@ -427,6 +429,12 @@ def source_status(self, plan, receipt) -> Receipt: self.fake.calls.append(("sol.source_status", receipt.status)) return self.source_status_result or receipt + def _transaction_logs(self, signature) -> list[str] | None: + self.fake.calls.append(("sol._transaction_logs", signature)) + if self.transaction_logs_error is not None: + raise self.transaction_logs_error + return self.transaction_logs_result + class FakeBridge: """Duck-typed stand-in for ``aleo_bridge.client.Bridge`` (no network, no extras).""" diff --git a/bridge-sdk/tests/test_get_status.py b/bridge-sdk/tests/test_get_status.py new file mode 100644 index 00000000..19060feb --- /dev/null +++ b/bridge-sdk/tests/test_get_status.py @@ -0,0 +1,225 @@ +import pytest + +from aleo_bridge.encoding import (xreserve_deposit_payload, xreserve_hook_data, xreserve_message_hash, + xreserve_nonce_from_payload) +from aleo_bridge.errors import (CheckpointInvalidError, DeliveryUnknownError, UnsupportedRouteError) +from aleo_bridge.lifecycle import aleo_transaction_status, get_status, prepare +from aleo_bridge.types import Attestation, Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +SIG = "0x" + "11" * 65 + + +def _inbound_private(b): + plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT, mint_mode="private") + hook = xreserve_hook_data("private", ALEO_RECIPIENT, "testnet", "7scalar") + payload = xreserve_deposit_payload(amount=2_000_000, remote_domain=10_002, remote_token=b"\x11" * 32, + remote_recipient=b"\x22" * 32, + local_token="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + depositor=EVM_ADDRESS, max_fee=100_000, nonce=b"\x00" * 32, hook_data=hook) + message_hash = "0x" + xreserve_message_hash(payload).hex() + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.ATTESTATION_PENDING, + source_tx_id="0x" + "22" * 32, + protocol_state={"routeId": plan.route_id, "mintMode": "private", + "intendedRecipient": ALEO_RECIPIENT, "payload": "0x" + payload.hex(), + "messageHash": message_hash, "bridgeProgram": "test_usdcx_bridge_v2.aleo"}) + return plan, payload, message_hash, receipt + + +def test_guards_and_terminal_passthrough(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + with pytest.raises(CheckpointInvalidError, match="does not match"): + get_status(b, plan, Receipt(id="x", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": "other"})) + done = Receipt(id="x", protocol="hyperlane", status=Status.COMPLETED, protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, done) is done + failed = done.replace(status=Status.FAILED) + assert get_status(b, plan, failed) is failed and b.calls == [] and b.events == [] + expired = done.replace(status=Status.EXPIRED) + assert get_status(b, plan, expired) is expired and b.calls == [] and b.events == [] + + +def test_branch1_evm_approval_pending_delegates_to_eth_source_status(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT) + receipt = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + b.eth.source_status_result = receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING) + out = get_status(b, plan, receipt) + assert out.status is Status.SOURCE_SUBMISSION_PENDING and b.calls == [("eth.source_status", Status.SOURCE_APPROVAL_PENDING)] + + +@pytest.mark.parametrize("node_status,expected", [("accepted", Status.DELIVERY_PENDING), ("rejected", Status.FAILED)]) +def test_branch2_aleo_source_confirming_reads_confirmed_transaction(node_status, expected): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM_ADDRESS) + receipt = Receipt(id="at1burn", protocol="xreserve", status=Status.SOURCE_CONFIRMING, source_tx_id="at1burn", + protocol_state={"routeId": plan.route_id}, next_action={"kind": "stale"}) + pending = get_status(b, plan, receipt) + assert pending is receipt # TransactionNotFound → unchanged + b.aleo.confirmed_transactions["at1burn"] = {"status": node_status, "type": "execute", "index": 3, + "transaction": {"id": "at1burn"}, "finalize": []} + out = get_status(b, plan, receipt) + assert out.status is expected and out.next_action is None + if expected is Status.FAILED: + assert "rejected" in out.protocol_state["sourceError"] + assert aleo_transaction_status(b, "at1burn")[0] == node_status + assert aleo_transaction_status(b, "at1unknown") == ("pending", None) + + +def test_branch3_4_hyperlane_source_confirming_evm_and_solana(): + b = FakeBridge(solana=True) + evm_plan = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + evm_receipt = Receipt(id="0x" + "aa" * 32, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="0x" + "aa" * 32, protocol_state={"routeId": evm_plan.route_id}) + b.eth.source_status_result = evm_receipt.replace(status=Status.DELIVERY_PENDING, + protocol_state={**evm_receipt.protocol_state, "messageId": "0x" + "cd" * 32}) + assert get_status(b, evm_plan, evm_receipt).protocol_state["messageId"] == "0x" + "cd" * 32 + sol_plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sol_receipt = Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": sol_plan.route_id}) + b.sol.source_status_result = sol_receipt.replace(status=Status.EXPIRED, + protocol_state={**sol_receipt.protocol_state, "blockhashExpired": True, + "sourceError": "Solana transaction expired before confirmation: sig"}) + assert get_status(b, sol_plan, sol_receipt).status is Status.EXPIRED + assert b.calls == [("eth.source_status", Status.SOURCE_CONFIRMING), ("sol.source_status", Status.SOURCE_CONFIRMING)] + + +def test_branch5_hyperlane_delivery_via_destination_mailbox(): + b = FakeBridge() + to_aleo = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + mid = "0x" + "cd" * 32 + receipt = Receipt(id=mid, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="0x" + "aa" * 32, + protocol_state={"routeId": to_aleo.route_id, "messageId": mid}) + assert get_status(b, to_aleo, receipt) is receipt + b.hyperlane.delivered[mid] = True + assert get_status(b, to_aleo, receipt).status is Status.COMPLETED + assert b.calls[-1] == ("hyperlane.is_delivered", mid) + + to_evm = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + receipt2 = Receipt(id="at1x", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1x", + protocol_state={"routeId": to_evm.route_id, "messageId": mid}) + b.eth.delivered[mid] = True + assert get_status(b, to_evm, receipt2).status is Status.COMPLETED + assert b.calls[-1] == ("eth.is_delivered", mid) + + +def test_branch5_solana_delivery_pending_without_message_id_fills_from_logs(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=sig, + protocol_state={"routeId": plan.route_id, "messageIdUnavailable": True}) + + # the log read raises -> unchanged, never falls through to is_delivered with the signature + b.sol.transaction_logs_error = RuntimeError("solana RPC is down") + assert get_status(b, plan, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + b.sol.transaction_logs_error = None + + # the log read succeeds but carries no dispatch line -> still unavailable, unchanged + b.sol.transaction_logs_result = ["Program log: something unrelated"] + assert get_status(b, plan, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + + # the dispatch line is present -> message id filled in, then the destination Mailbox is checked + mid = "0x" + "cd" * 32 + b.sol.transaction_logs_result = [f"Program log: Dispatched message to 1399811149, ID {mid}"] + out = get_status(b, plan, receipt) + assert out.id == mid + assert out.protocol_state["messageId"] == mid + assert "messageIdUnavailable" not in out.protocol_state + assert out.status is Status.DELIVERY_PENDING + assert b.calls[-1] == ("hyperlane.is_delivered", mid) + + b.hyperlane.delivered[mid] = True + assert get_status(b, plan, receipt).status is Status.COMPLETED + + +def test_branch6_aleo_origin_balance_diff_fallback(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + b.sol.balance_lamports = 100 + assert get_status(b, plan, receipt) is receipt + b.sol.balance_lamports = 101 + assert get_status(b, plan, receipt).status is Status.COMPLETED + no_baseline = receipt.replace(protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, no_baseline) is no_baseline # branch 7: unchanged + b2 = FakeBridge(ethereum=False) + with pytest.raises(DeliveryUnknownError, match="destination balance"): + get_status(b2, plan, receipt) + + +def test_branch8_and_9_xreserve_outbound_and_not_implemented(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM_ADDRESS) + receipt = Receipt(id="at1burn", protocol="xreserve", status=Status.DELIVERY_PENDING, source_tx_id="at1burn", + protocol_state={"routeId": plan.route_id}) + assert get_status(b, plan, receipt) is receipt + with pytest.raises(UnsupportedRouteError, match="not implemented"): + get_status(b, plan, receipt.replace(status=Status.ATTESTATION_PENDING)) + + +def test_branch10_nullifier_first_then_attestation_then_private_action(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + # nullifier read comes first for every inbound pending status, and derives the nonce from the payload + b.xreserve.delivered_nonces.add("0x" + xreserve_nonce_from_payload(payload).hex()) + waiting = receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": "aleo-testnet"}) + out = get_status(b, plan, waiting) + assert out.status is Status.COMPLETED and out.next_action is None + assert b.calls[0][0] == "xreserve.is_delivered" + b.xreserve.delivered_nonces.clear() + # stored nonce wins over the payload + stored = receipt.replace(status=Status.DELIVERY_PENDING, + protocol_state={**receipt.protocol_state, "nonce": "0x" + "33" * 32}) + b.xreserve.delivered_nonces.add("0x" + "33" * 32) + assert get_status(b, plan, stored).status is Status.COMPLETED + b.xreserve.delivered_nonces.clear() + # attestation pending → unchanged; complete + private → DESTINATION_ACTION_REQUIRED + assert get_status(b, plan, receipt) is receipt + b.xreserve.attestations[message_hash] = Attestation(payload=payload, message_hash=bytes.fromhex(message_hash[2:]), + attestation=bytes.fromhex(SIG[2:]), status="complete") + ready = get_status(b, plan, receipt) + assert ready.status is Status.DESTINATION_ACTION_REQUIRED + assert ready.next_action == {"kind": "xreserve-private-mint", "chainId": "aleo-testnet"} + assert ready.protocol_state["attestation"] == SIG + assert get_status(b, plan, ready) is ready # action required → unchanged + # public mode → DELIVERY_PENDING with attestation kept + public_plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT) + public = receipt.replace(protocol_state={**receipt.protocol_state, "mintMode": "public"}) + out = get_status(b, public_plan, public) + assert out.status is Status.DELIVERY_PENDING and out.protocol_state["attestation"] == SIG + with pytest.raises(CheckpointInvalidError, match="message hash"): + get_status(b, plan, receipt.replace(protocol_state={"routeId": plan.route_id})) + + +def test_branch10_source_confirming_and_destination_confirming(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + confirming = receipt.replace(status=Status.SOURCE_CONFIRMING) + b.eth.source_status_result = receipt + assert get_status(b, plan, confirming) is receipt and b.calls[-1][0] == "eth.source_status" + minting = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id="at1private", + protocol_state={**receipt.protocol_state, "attestation": SIG}) + assert get_status(b, plan, minting) is minting + b.aleo.confirmed_transactions["at1private"] = {"status": "accepted", "type": "execute"} + assert get_status(b, plan, minting).status is Status.COMPLETED + b.aleo.confirmed_transactions["at1private"] = {"status": "rejected", "type": "execute", "rejected": {"type": "execution"}} + out = get_status(b, plan, minting) + assert out.status is Status.FAILED and "at1private" in out.protocol_state["destinationError"] + with pytest.raises(CheckpointInvalidError, match="destination transaction id"): + get_status(b, plan, minting.replace(destination_tx_id=None)) From 682a199550d4a98caf0114ec18333c7267c9fe36 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:11:11 -0400 Subject: [PATCH 73/94] fix(bridge-sdk): save-then-delete checkpoints, gate Aleo legs on the 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 103 ++++++++++++++++----- bridge-sdk/tests/test_execute.py | 81 ++++++++++++++++ 2 files changed, 160 insertions(+), 24 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 37289c09..511bc4d1 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -223,41 +223,48 @@ def quote(bridge, *, source, destination, amount=None, amount_atomic=None, recip # ── Checkpoint emission ─────────────────────────────────────────────────────── -def _persist(bridge, checkpoint: Checkpoint, receipt: Receipt | None, *, previous_id: str | None = None) -> None: - """Mirror *checkpoint* into the bound store: supersede the previous id, drop terminal ones. - - ``receipt`` is ``None`` for a boundary the protocol module reduced and saved itself: the - supersede still runs (a module only ever saves — it never deletes the checkpoint its own - next boundary replaces), the save does not. +def _persist(bridge, checkpoint: Checkpoint, receipt: Receipt, *, previous_id: str | None = None) -> None: + """Mirror *checkpoint* into the bound store: save (or drop, if terminal) BEFORE superseding + the previous id — never the reverse, so a crash between the two steps still leaves a valid + record for the transfer rather than a moment where the store holds neither. """ store = getattr(bridge, "checkpoints", None) if store is None: return - if previous_id is not None and previous_id != checkpoint.id: - store.delete(previous_id) - if receipt is None: - return if receipt.status in TERMINAL: store.delete(checkpoint.id) else: store.save(checkpoint) + if previous_id is not None and previous_id != checkpoint.id: + store.delete(previous_id) class _Emitter: """Turns receipts into checkpoints: caller callback first, then the bound store. Two channels feed it — a protocol module's own ``on_checkpoint`` (which hands over a - ``Checkpoint`` it has already reduced and saved) and ``execute``'s own emission once the - send returns. A boundary that arrives through both is handed to the caller once: the two - reductions compare equal, being the same receipt reduced against the same plan. + ``Checkpoint`` it has already reduced, and saves ITSELF only after this call returns) and + ``execute``'s own emission once the send returns. A boundary that arrives through both is + handed to the caller once: the two reductions compare equal, being the same receipt reduced + against the same plan. + + A module-emitted checkpoint supersedes the previous id before the module has actually saved + the new one — deleting the old id here (save-then-delete, brief §review item 6) would leave a + window where the store holds neither if it crashed. So that delete is parked as + ``_pending_supersede`` and only carried out once we know the module's save has landed: at the + start of the next emission (module-emitted or not — the loop that owns the module has already + returned from its ``store.save`` by then) or, failing that, when ``execute`` calls + :meth:`finalize` after its own last receipt is persisted. """ def __init__(self, bridge, plan: Plan, on_checkpoint: Callable | None) -> None: self._bridge, self._plan, self._cb = bridge, plan, on_checkpoint self._last_id: str | None = None self._last: Checkpoint | None = None + self._pending_supersede: str | None = None def __call__(self, receipt) -> Checkpoint: + self._flush_pending() module_emitted = isinstance(receipt, Checkpoint) checkpoint = receipt if module_emitted else create_checkpoint(self._plan, receipt, self._bridge.registry) if checkpoint != self._last: @@ -267,10 +274,27 @@ def __call__(self, receipt) -> Checkpoint: self._bridge.events.append((f"checkpoint:{label}", checkpoint.id)) if self._cb is not None: self._cb(checkpoint) # the caller's own callback: errors are theirs - _persist(self._bridge, checkpoint, None if module_emitted else receipt, previous_id=self._last_id) - self._last_id, self._last = checkpoint.id, checkpoint + if module_emitted: + self._pending_supersede = self._last_id # module saves this one itself, after we return + else: + _persist(self._bridge, checkpoint, receipt, previous_id=self._last_id) + self._last_id, self._last = checkpoint.id, checkpoint return checkpoint + def _flush_pending(self) -> None: + if self._pending_supersede is None: + return + pending, self._pending_supersede = self._pending_supersede, None + if pending == self._last_id: + return + store = getattr(self._bridge, "checkpoints", None) + if store is not None: + store.delete(pending) + + def finalize(self) -> None: + """Drop any still-pending supersede. Call once execute()'s final receipt is persisted.""" + self._flush_pending() + # ── Execution helpers ───────────────────────────────────────────────────────── @@ -289,12 +313,26 @@ def _assert_sender(plan: Plan, address: str | None, *, family: str) -> None: "Re-quote with sender=None or the connection's own address.") +def _connected_aleo_address(bridge) -> str | None: + """``bridge.aleo_address()``, or None when no account is configured to sign with. + + A read-only facade cannot sign an Aleo leg anyway, so a missing account is not this check's + problem to raise on — it just means there is nothing to compare the plan's sender against. + """ + try: + return bridge.aleo_address() + except (ConfigurationError, AttributeError): + return None + + def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> int | None: """The recipient's destination balance, or None when we cannot read it. Only read when the destination connection IS the recipient (there is no per-address balance read in the module contracts); otherwise omit the delivery-verification pair rather than - baseline the wrong account. + baseline the wrong account. The RPC read itself is best-effort (review item 8): a transient + failure here is only ever used as an advisory baseline (``execute``'s pre-broadcast checkpoint) + or re-attempted by ``get_status``/``wait`` — it must never raise and block funds movement. """ chain, asset = resolved.destination_chain, resolved.destination_asset if chain.family == "evm": @@ -303,13 +341,19 @@ def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> in or conn.address.lower() != plan.recipient.lower() or asset.locator is None or asset.locator.kind not in ("native", "evm-contract")): return None - return int(bridge.eth.balance(asset.id)) + try: + return int(bridge.eth.balance(asset.id)) + except Exception: # noqa: BLE001 — advisory read only + return None if chain.family == "solana": conn = getattr(bridge, "solana", None) if (conn is None or conn.address != plan.recipient or asset.locator is None or asset.locator.kind != "native"): return None - return int(bridge.sol.balance()) + try: + return int(bridge.sol.balance()) + except Exception: # noqa: BLE001 — advisory read only + return None return None # Aleo private records / token mappings: protocol signal instead @@ -421,15 +465,20 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin eth = _module(bridge, "eth") _assert_sender(plan, bridge.ethereum.address, family=family) call = eth.transfer_remote(plan=plan) - return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) if plan.protocol == "hyperlane" and family == "solana": sol = _module(bridge, "sol") _assert_sender(plan, bridge.solana.address, family=family) call = sol.transfer_remote(plan=plan) - return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) if plan.protocol == "hyperlane" and family == "aleo": + _assert_sender(plan, _connected_aleo_address(bridge), family="aleo") as_signer = _aleo_hyperlane_mode(mode) verification = _delivery_verification(bridge, plan, resolved) gas = gas_payment_microcredits @@ -438,21 +487,27 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin call = bridge.hyperlane.transfer_remote(plan.source_asset_id, plan.recipient, amount_atomic=plan.amount_atomic, as_signer=as_signer, gas_payment_microcredits=gas) - return to_progress(plan, _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, - extra_state=verification)) + receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state=verification) + emit.finalize() + return to_progress(plan, receipt) if plan.protocol == "xreserve" and family == "evm": eth = _module(bridge, "eth") _assert_sender(plan, bridge.ethereum.address, family=family) nonce = _mint_secret(plan, secret_nonce) call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) - return to_progress(plan, _send_call(call, emit, poll_seconds, timeout_seconds)) + receipt = _send_call(call, emit, poll_seconds, timeout_seconds) + emit.finalize() + return to_progress(plan, receipt) if plan.protocol == "xreserve" and family == "aleo": + _assert_sender(plan, _connected_aleo_address(bridge), family="aleo") burn_mode = _xreserve_burn_mode(mode) call = bridge.xreserve.burn(plan.recipient, amount_atomic=plan.amount_atomic, mode=burn_mode, record=record, merkle_proof=merkle_proof) - return to_progress(plan, _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state={})) + receipt = _run_aleo_leg(bridge, plan, call, proving=proving, emit=emit, extra_state={}) + emit.finalize() + return to_progress(plan, receipt) raise UnsupportedRouteError(f"Unsupported {plan.protocol} source chain family: {family} ({plan.route_id})") diff --git a/bridge-sdk/tests/test_execute.py b/bridge-sdk/tests/test_execute.py index 441aae43..ced72143 100644 --- a/bridge-sdk/tests/test_execute.py +++ b/bridge-sdk/tests/test_execute.py @@ -242,3 +242,84 @@ def test_a_stale_plan_is_refused_before_anything_is_sent(): with pytest.raises(Exception, match="registry"): execute(b, plan) assert b.calls == [] and b.events == [] + + +# ── review carry-overs (items 6-8) ──────────────────────────────────────────── + +def test_persist_never_leaves_the_store_empty_between_checkpoints(tmp_path): + """Item 6: ``_persist`` saves the new checkpoint before deleting the superseded id, and a + module-emitted checkpoint's own supersede is deferred (parked on the ``_Emitter``) until the + module has actually saved it — otherwise a crash between delete and save loses the record.""" + store = FileCheckpointStore(tmp_path) + observed = [] + orig_save, orig_delete = store.save, store.delete + + def save(cp): + orig_save(cp) + observed.append(len(store.list())) + + def delete(cid): + orig_delete(cid) + observed.append(len(store.list())) + + store.save, store.delete = save, delete + b = FakeBridge(checkpoints=store) + plan = _wbtc_plan(b, sender=EVM_ADDRESS) + b.eth.intermediates = [Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]})] + execute(b, plan) + assert observed and all(n >= 1 for n in observed) # never empty in between + assert [c.id for c in store.list()] == ["0x" + "aa" * 32] # exactly one record after execute + + +def test_aleo_hyperlane_leg_refuses_a_sender_mismatch_before_proving(): + """Item 7: an Aleo leg checks the plan's sender against ``bridge.aleo_address()`` before + proving anything.""" + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS, + sender="aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqvfnl2t") + with pytest.raises(ConfigurationError, match="sender"): + execute(b, plan, gas_payment_microcredits=1) + assert b.calls == [] and "delegate_prepared" not in [e[0] for e in b.events] + + +def test_aleo_xreserve_leg_refuses_a_sender_mismatch_before_proving(): + b = FakeBridge(ethereum=False) + plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.5", + recipient=EVM_ADDRESS, + sender="aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqvfnl2t") + with pytest.raises(ConfigurationError, match="sender"): + execute(b, plan, mode="public") + assert b.calls == [] and "delegate_prepared" not in [e[0] for e in b.events] + + +def test_aleo_leg_with_no_sender_pinned_and_no_configured_account_is_unaffected(): + """A plan without a sender, or a bridge whose ``aleo_address()`` cannot be read, never blocks + the leg — the check is a no-op when there is nothing to compare.""" + b = FakeBridge(ethereum=False) + + def raises(*a, **kw): + raise ConfigurationError("no aleo account configured") + + b.aleo_address = raises + execute(b, _aleo_eth_plan(b)) + assert b.calls[0][0] == "hyperlane.quote_gas_payment" + + +def test_destination_balance_baseline_read_is_best_effort(monkeypatch): + """Item 8: an advisory destination-balance read never blocks funds movement.""" + b = FakeBridge() + b.eth.balances["ethereum/eth"] = 100 + + def raise_balance(asset): + raise RuntimeError("RPC is down") + + monkeypatch.setattr(b.eth, "balance", raise_balance) + plan = _aleo_eth_plan(b) + cps = [] + progress = execute(b, plan, gas_payment_microcredits=1, on_checkpoint=cps.append) + assert cps[0].delivery_verification is None + assert "destinationBalanceBeforeAtomic" not in progress.receipt.protocol_state From f33edd78e65f6b2d67c281716a74e5a9717b2554 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:11:57 -0400 Subject: [PATCH 74/94] feat(bridge-sdk): wait() with caller boundaries, until, transient-error 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 134 +++++++++++++++- bridge-sdk/tests/test_wait.py | 170 +++++++++++++++++++++ 2 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 bridge-sdk/tests/test_wait.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 511bc4d1..ff77b7ae 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +import time from dataclasses import dataclass, replace from typing import Any, Callable @@ -21,18 +22,20 @@ from ._plan import build_plan from .checkpoint import Checkpoint, create_checkpoint from .errors import ( + BridgeError, CheckpointInvalidError, ConfigurationError, DeliveryUnknownError, InvalidAmountError, InvalidRecipientError, + PollingTimeoutError, RegistryVersionMismatchError, RouteUnavailableError, UnsupportedRouteError, ) from .registry import Asset, Chain, Registry, Route -from .types import (TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, Progress, Quote, - Receipt, Status, to_progress) +from .types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, + Progress, Quote, Receipt, Status, to_progress) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount MINT_MODES = ("public", "record", "private") @@ -719,5 +722,130 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: return receipt +# ── wait ────────────────────────────────────────────────────────────────────── + +def _track(bridge, plan: Plan, previous: Receipt, current: Receipt) -> None: + """Mirror an observed status change into the bound store (no caller callback).""" + if getattr(bridge, "checkpoints", None) is None: + return + _persist(bridge, create_checkpoint(plan, current, bridge.registry), current, previous_id=previous.id) + + +def _status_set(values) -> set[Status]: + try: + return {v if isinstance(v, Status) else Status(v) for v in values} + except ValueError as exc: + raise ConfigurationError(f"wait(until=...) contains an unknown status: {exc}") from exc + + +_TRANSIENT_BRIDGE_ERROR_RE = re.compile(r"HTTP status (429|5\d\d)|request failed:") + + +def _is_transient_error(exc: Exception) -> bool: + """``wait``'s retry classifier: a network/RPC hiccup vs. a real problem that must propagate. + + Transient: ``requests.RequestException`` (any ``requests``-based transport), ``aleo.facade. + errors.AleoNetworkError`` (the Aleo facade), a ``BridgeError`` whose message matches an HTTP + 429/5xx or a wrapped "request failed:" transport error (``SolanaRpcClient``), or a web3 + provider/connection error. Everything else — including programming errors, ``BridgeError``s + about an actual on-chain failure, ``CheckpointInvalidError``, ``RouteUnavailableError`` — + propagates immediately. + """ + try: + import requests + if isinstance(exc, requests.RequestException): + return True + except ImportError: + pass + try: + from aleo.facade.errors import AleoNetworkError + if isinstance(exc, AleoNetworkError): + return True + except ImportError: + pass + try: + from web3.exceptions import ProviderConnectionError + if isinstance(exc, ProviderConnectionError): + return True + except ImportError: + if type(exc).__name__ == "ProviderConnectionError": # web3 extra not installed here + return True + if isinstance(exc, BridgeError) and _TRANSIENT_BRIDGE_ERROR_RE.search(str(exc)): + return True + return False + + +def wait(bridge, progress: Progress, *, until=None, poll_seconds: float = 15.0, + timeout_seconds: float = 1200.0, on_update: Callable[[Progress], Any] | None = None, + on_error: Callable[[Exception], Any] | None = None, max_consecutive_errors: int = 5) -> Progress: + """Poll ``get_status`` until the transfer needs the caller or finishes. + + Always stops at the caller boundaries — ``SOURCE_SUBMISSION_PENDING`` (→ ``resume``), + ``DESTINATION_ACTION_REQUIRED`` (→ ``complete``), ``COMPLETED``, ``FAILED``, ``EXPIRED`` — plus + any statuses in ``until`` (a ``Status`` or its name; ``until=[]`` is a ``ConfigurationError``, + an unknown name too). Returns immediately when ``progress.next != "wait"`` or the receipt is + already at a stop. ``on_update`` fires only when the receipt changed, never on a retry. + ``poll_seconds`` is floored at 0.1 unless exactly 0; negative ``poll_seconds``/``timeout_seconds`` + is a ``ConfigurationError``. + + A ``get_status`` call that raises a transient error (flaky RPC/HTTP transport — see + :func:`_is_transient_error`) is retried with the normal poll interval, up to + ``max_consecutive_errors`` (default 5) consecutive failures before the last one is re-raised; + ``on_error`` fires on each tolerated retry so callers can log them. A non-transient error + propagates immediately, on the first attempt. + + Hitting ``timeout_seconds`` raises ``PollingTimeoutError`` carrying the last ``status`` and + ``progress`` — a timeout is NOT a failure (invariant 5): the transfer is still in flight; call + ``wait`` again or ``recover`` later. + """ + if until is not None and len(until) == 0: + raise ConfigurationError("wait(until=[]) has nothing to stop at: pass at least one Status or omit until") + plan, receipt = progress.plan, progress.receipt + resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + stops = set(CALLER_BOUNDARIES) | _status_set(until or ()) + current = to_progress(plan, receipt) + if current.next != "wait" or receipt.status in stops: + return current + if poll_seconds < 0 or timeout_seconds < 0: + raise ConfigurationError("poll_seconds and timeout_seconds must be non-negative") + interval = 0.0 if poll_seconds == 0 else max(0.1, float(poll_seconds)) + deadline = time.monotonic() + timeout_seconds + updated = receipt + consecutive_errors = 0 + while True: + try: + nxt = get_status(bridge, plan, updated) + except Exception as exc: + if not _is_transient_error(exc): + raise + consecutive_errors += 1 + if consecutive_errors > max_consecutive_errors: + raise + if on_error is not None: + on_error(exc) + if time.monotonic() >= deadline: + raise PollingTimeoutError( + f"Bridge status polling timed out in state {updated.status.value}; the transfer is " + "still in flight — call wait() again or recover() from the last checkpoint. This is " + "not a failure.", status=updated.status, progress=to_progress(plan, updated)) from exc + time.sleep(interval) + continue + consecutive_errors = 0 + if nxt != updated: + _track(bridge, plan, updated, nxt) + if on_update is not None: + on_update(to_progress(plan, nxt)) + updated = nxt + if updated.status in stops: + return to_progress(plan, updated) + if time.monotonic() >= deadline: + raise PollingTimeoutError( + f"Bridge status polling timed out in state {updated.status.value}; the transfer is still " + "in flight — call wait() again or recover() from the last checkpoint. This is not a failure.", + status=updated.status, progress=to_progress(plan, updated)) + time.sleep(interval) + + __all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "execute", "get_status", "prepare", - "quote", "resolve_route"] + "quote", "resolve_route", "wait"] diff --git a/bridge-sdk/tests/test_wait.py b/bridge-sdk/tests/test_wait.py new file mode 100644 index 00000000..1437b7f8 --- /dev/null +++ b/bridge-sdk/tests/test_wait.py @@ -0,0 +1,170 @@ +"""``lifecycle.wait`` — poll ``get_status`` to a caller boundary. + +Always stops at ``types.CALLER_BOUNDARIES`` plus any statuses in ``until``, tolerates transient +read errors (network/RPC hiccups) up to ``max_consecutive_errors`` before giving up, and treats a +timeout as "still in flight", never as failure. +""" +import pytest + +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import BridgeError, CheckpointInvalidError, ConfigurationError, PollingTimeoutError +from aleo_bridge.lifecycle import prepare, wait +from aleo_bridge.types import Attestation, Progress, Receipt, Status, to_progress +from tests.fakes.fake_bridge import ALEO_RECIPIENT, SOL_ADDRESS, FakeBridge +from tests.test_get_status import SIG, _inbound_private + + +def _sol_progress(b): + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + return plan, to_progress(plan, receipt) + + +def test_returns_immediately_when_next_is_not_wait_or_status_is_a_boundary(): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + resume = to_progress(plan, progress.receipt.replace(status=Status.SOURCE_SUBMISSION_PENDING)) + assert wait(b, resume) is not None and wait(b, resume).next == "resume" and b.calls == [] + done = to_progress(plan, progress.receipt.replace(status=Status.COMPLETED)) + assert wait(b, done).next == "done" and b.calls == [] + + +def test_polls_until_completion_and_reports_only_changes(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + reads = iter([100, 100, 101]) + b.sol.balance = lambda: next(reads) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + updates = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_update=updates.append) + assert out.next == "done" and out.receipt.status is Status.COMPLETED + assert updates == [out] # two unchanged reads produced no update + assert slept == [0.0, 0.0] + + +def test_until_adds_stops_and_empty_until_is_an_error(monkeypatch): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + with pytest.raises(ConfigurationError, match="until"): + wait(b, to_progress(plan, receipt), until=[]) + calls = {"n": 0} + real_get = b.xreserve.get_attestation + + def flaky(message_hash, *, route=None): + calls["n"] += 1 + return None if calls["n"] == 1 else Attestation(payload, bytes.fromhex(message_hash[2:]), bytes.fromhex(SIG[2:]), "complete") + b.xreserve.get_attestation = flaky + updates = [] + out = wait(b, to_progress(plan, receipt), until=[Status.DESTINATION_ACTION_REQUIRED], poll_seconds=0, + timeout_seconds=10, on_update=updates.append) + assert calls["n"] == 2 and out.next == "complete" and updates == [out] + # string statuses are accepted in until + calls["n"] = 0 + assert wait(b, to_progress(plan, receipt), until=["DESTINATION_ACTION_REQUIRED"], poll_seconds=0, + timeout_seconds=10).next == "complete" + + +def test_until_rejects_an_unknown_status_name(): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + with pytest.raises(ConfigurationError): + wait(b, progress, until=["NOT_A_REAL_STATUS"]) + + +def test_timeout_carries_status_and_progress(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + b.sol.balance_lamports = 100 + clock = iter([0.0, 0.0, 5.0, 11.0]) + monkeypatch.setattr("aleo_bridge.lifecycle.time.monotonic", lambda: next(clock)) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + with pytest.raises(PollingTimeoutError, match="DELIVERY_PENDING") as exc: + wait(b, progress, poll_seconds=0.05, timeout_seconds=10) + assert exc.value.status is Status.DELIVERY_PENDING + assert isinstance(exc.value.progress, Progress) and exc.value.progress.next == "wait" + + +def test_poll_floor_and_negative_controls(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + reads = iter([100, 101]) + b.sol.balance = lambda: next(reads) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + wait(b, progress, poll_seconds=0.01, timeout_seconds=10) + assert slept == [0.1] # floor 0.1 unless exactly 0 + with pytest.raises(ConfigurationError): + wait(b, progress, poll_seconds=-1) + with pytest.raises(ConfigurationError): + wait(b, progress, timeout_seconds=-1) + + +def test_bound_store_tracks_changes_and_deletes_terminal(tmp_path, monkeypatch): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + plan, progress = _sol_progress(b) + from aleo_bridge.checkpoint import create_checkpoint + store.save(create_checkpoint(plan, progress.receipt, b.registry)) + b.sol.balance_lamports = 101 + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + assert wait(b, progress, poll_seconds=0, timeout_seconds=10).next == "done" + assert store.list() == [] + + +# ── transient-error tolerance (controller notes item 1) ─────────────────────── + +def test_transient_errors_are_retried_with_the_normal_poll_interval(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + slept = [] + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", slept.append) + calls = {"n": 0} + + def flaky(bridge, plan_, receipt): + calls["n"] += 1 + if calls["n"] <= 3: + raise BridgeError("Solana RPC request failed with HTTP status 429") + return receipt.replace(status=Status.COMPLETED) + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", flaky) + errors = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_error=errors.append) + assert calls["n"] == 4 + assert out.next == "done" and out.receipt.status is Status.COMPLETED + assert len(errors) == 3 and all(isinstance(e, BridgeError) for e in errors) + assert slept == [0.0, 0.0, 0.0] + + +def test_six_consecutive_transient_errors_reraise_the_sixth(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + calls = {"n": 0} + + def always_flaky(bridge, plan_, receipt): + calls["n"] += 1 + raise BridgeError("request failed: connection reset") + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", always_flaky) + with pytest.raises(BridgeError, match="connection reset"): + wait(b, progress, poll_seconds=0, timeout_seconds=10) + assert calls["n"] == 6 # five tolerated, the sixth re-raises + + +def test_a_non_transient_error_propagates_on_the_first_attempt(monkeypatch): + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + calls = {"n": 0} + + def raises_checkpoint_invalid(bridge, plan_, receipt): + calls["n"] += 1 + raise CheckpointInvalidError("stale checkpoint") + + monkeypatch.setattr("aleo_bridge.lifecycle.get_status", raises_checkpoint_invalid) + with pytest.raises(CheckpointInvalidError, match="stale checkpoint"): + wait(b, progress, poll_seconds=0, timeout_seconds=10) + assert calls["n"] == 1 From cfc4479908fb01976441a2bcf952b1b82c52fe65 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:16:27 -0400 Subject: [PATCH 75/94] fix(bridge-sdk): stop _message_id aliasing source tx hashes and stop 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 11 +++-- bridge-sdk/tests/test_get_status.py | 48 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index ff77b7ae..11c0bba3 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -567,13 +567,17 @@ def _message_id(receipt: Receipt) -> str | None: Solana and EVM Hyperlane receipts carry the id in ``protocol_state["messageId"]`` once known; a receipt that instead carries the message id AS its own ``id`` (some Aleo-origin shapes) falls back to that, but only when it is an exact 32-byte ``0x`` hex string — never a - signature or an unrelated transaction hash of a different width. + signature or an unrelated transaction hash of a different width. Never falls back when + ``receipt.id == receipt.source_tx_id``: an EVM Hyperlane receipt whose DispatchId log was + unreadable carries the source transaction hash as its id, which is a same-shaped ``0x`` hex + string but is NOT a message id. """ state = receipt.protocol_state message_id = state.get("messageId") if isinstance(message_id, str) and message_id: return message_id - if isinstance(receipt.id, str) and _MESSAGE_ID_RE.fullmatch(receipt.id): + if (isinstance(receipt.id, str) and receipt.id != receipt.source_tx_id + and _MESSAGE_ID_RE.fullmatch(receipt.id)): return receipt.id return None @@ -633,8 +637,9 @@ def get_status(bridge, plan: Plan, receipt: Receipt) -> Receipt: message_id = _message_id(receipt) if (message_id is None and receipt.status is Status.DELIVERY_PENDING and route.protocol == "hyperlane" and src.family == "solana" and state.get("messageIdUnavailable") and receipt.source_tx_id): + sol = _module(bridge, "sol") # missing connection must raise, not be swallowed try: - logs = _module(bridge, "sol")._transaction_logs(receipt.source_tx_id) + logs = sol._transaction_logs(receipt.source_tx_id) except Exception: # noqa: BLE001 — advisory fill-in only logs = None filled = None if logs is None else _sealevel.extract_hyperlane_message_id(logs) diff --git a/bridge-sdk/tests/test_get_status.py b/bridge-sdk/tests/test_get_status.py index 19060feb..ba385294 100644 --- a/bridge-sdk/tests/test_get_status.py +++ b/bridge-sdk/tests/test_get_status.py @@ -2,7 +2,8 @@ from aleo_bridge.encoding import (xreserve_deposit_payload, xreserve_hook_data, xreserve_message_hash, xreserve_nonce_from_payload) -from aleo_bridge.errors import (CheckpointInvalidError, DeliveryUnknownError, UnsupportedRouteError) +from aleo_bridge.errors import (CheckpointInvalidError, ConfigurationError, DeliveryUnknownError, + UnsupportedRouteError) from aleo_bridge.lifecycle import aleo_transaction_status, get_status, prepare from aleo_bridge.types import Attestation, Receipt, Status from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge @@ -223,3 +224,48 @@ def test_branch10_source_confirming_and_destination_confirming(): assert out.status is Status.FAILED and "at1private" in out.protocol_state["destinationError"] with pytest.raises(CheckpointInvalidError, match="destination transaction id"): get_status(b, plan, minting.replace(destination_tx_id=None)) + + +def test_branch10_delivered_wins_over_attestation_pending(): + # Carried from the Task 5 review (item 7): the destination nullifier check (invariant 6) must + # run BEFORE the attestation read for EVERY inbound-pending status, including + # ATTESTATION_PENDING itself, not just DESTINATION_ACTION_REQUIRED/DELIVERY_PENDING — so a + # nonce that is already delivered short-circuits straight to COMPLETED even when a complete + # attestation is also scripted, and xreserve.get_attestation is never called. + b = FakeBridge(environment="testnet") + plan, payload, message_hash, receipt = _inbound_private(b) + nonce = "0x" + xreserve_nonce_from_payload(payload).hex() + b.xreserve.delivered_nonces.add(nonce) + b.xreserve.attestations[message_hash] = Attestation(payload=payload, message_hash=bytes.fromhex(message_hash[2:]), + attestation=bytes.fromhex(SIG[2:]), status="complete") + out = get_status(b, plan, receipt) + assert out.status is Status.COMPLETED + assert not any(call[0] == "xreserve.get_attestation" for call in b.calls) + + +def test_message_id_never_falls_back_to_source_tx_id(): + # Carried from the Task 5 review (item 8): a DispatchId log that could not be read leaves + # receipt.id == receipt.source_tx_id (the source transaction hash) — that must never be + # mistaken for the Hyperlane message id, even though it has the same 0x + 64-hex shape. + b = FakeBridge() + to_aleo = prepare(b.registry, source="ethereum/eth", destination="aleo/eth", amount="0.000000000000000001", + recipient=ALEO_RECIPIENT) + tx = "0x" + "aa" * 32 + receipt = Receipt(id=tx, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=tx, + protocol_state={"routeId": to_aleo.route_id}) + assert get_status(b, to_aleo, receipt) is receipt + assert not any(call[0] == "hyperlane.is_delivered" for call in b.calls) + + +def test_solana_message_id_fill_in_raises_on_missing_connection(): + # Carried from the Task 5 review (item 9): resolving the Solana connection must happen OUTSIDE + # the log-read try/except, so a missing connection surfaces as ConfigurationError instead of + # being swallowed as "still unavailable". + b = FakeBridge(ethereum=False, solana=False) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.DELIVERY_PENDING, source_tx_id=sig, + protocol_state={"routeId": plan.route_id, "messageIdUnavailable": True}) + with pytest.raises(ConfigurationError, match="Solana"): + get_status(b, plan, receipt) From 2b46d1c2784a0cdc0bd4f4ac4793501fa397a42d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:20:16 -0400 Subject: [PATCH 76/94] feat(bridge-sdk): recover() rebuilds progress from checkpoints without 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 167 +++++++++++++++++- bridge-sdk/tests/test_recover.py | 189 +++++++++++++++++++++ 2 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 bridge-sdk/tests/test_recover.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 11c0bba3..68a1e377 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -13,6 +13,7 @@ """ from __future__ import annotations +import json import re import time from dataclasses import dataclass, replace @@ -853,4 +854,168 @@ def wait(bridge, progress: Progress, *, until=None, poll_seconds: float = 15.0, __all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "execute", "get_status", "prepare", - "quote", "resolve_route", "wait"] + "quote", "recover", "resolve_route", "wait"] + + +# ── recover ─────────────────────────────────────────────────────────────────── + +def _coerce_checkpoint(value): + if isinstance(value, Checkpoint): + return value + if isinstance(value, str): + return Checkpoint.from_json(value) + if isinstance(value, dict): + return Checkpoint.from_dict(value) + raise CheckpointInvalidError(f"recover() takes a Checkpoint, its dict, or its JSON; got {type(value).__name__}") + + +def _plan_from_intent(registry: Registry, intent: dict[str, Any]) -> Plan: + """Rebuild the ``Plan`` behind a checkpoint by re-running ``prepare`` on its intent. + + ``prepare`` is only ever a thin validating wrapper around ``_plan.build_plan`` + (proven field-identical for every active route — see + ``tests/test_prepare.py::test_prepare_equals_build_plan_for_every_active_route``), so a + recovered plan is exactly what the original ``prepare()``/``quote()`` call produced and + passes ``EthModule``/``SolModule``'s field-by-field ``plan=`` checks. + """ + try: + return prepare(registry, + source=(intent["source"]["chain"], intent["source"]["asset"]), + destination=(intent["destination"]["chain"], intent["destination"]["asset"]), + amount=intent["amount"], recipient=intent["recipient"], sender=intent.get("sender"), + protocol=intent.get("bridgeProtocol"), mint_mode=intent.get("mintMode", "public")) + except (KeyError, TypeError) as exc: + raise CheckpointInvalidError(f"Bridge checkpoint intent is incomplete: missing {exc}") from exc + + +def _assert_prepared_id(serialized: Any, expected_id: str, what: str = "prepared Aleo transaction") -> str: + """The serialized transaction's ``id`` must equal the saved id — never substitute bytes.""" + if not isinstance(serialized, str) or not serialized: + raise CheckpointInvalidError(f"Bridge checkpoint contains an invalid {what} (empty)") + try: + decoded = json.loads(serialized) + except json.JSONDecodeError as exc: + raise CheckpointInvalidError(f"Bridge checkpoint contains an invalid {what}: not JSON") from exc + tx_id = decoded.get("id") if isinstance(decoded, dict) else None + if not isinstance(tx_id, str) or tx_id != expected_id: + raise CheckpointInvalidError(f"Bridge checkpoint {what} id does not match its payload " + f"({tx_id!r} != {expected_id!r})") + return tx_id + + +def _finish(bridge, plan: Plan, receipt: Receipt, checkpoint_id: str) -> Progress: + """Reduce *receipt* to ``Progress``, dropping the checkpoint (keyed on ``checkpoint_id`` — + the checkpoint's OWN id, never ``receipt.id``: for Solana and EVM Hyperlane the checkpoint's + id is the source transaction id while the receipt's own id flips to the message id once + confirmed) from the bound store once the transfer reaches a terminal status. + """ + store = getattr(bridge, "checkpoints", None) + if store is not None and receipt.status in TERMINAL: + store.delete(checkpoint_id) + return to_progress(plan, receipt) + + +def recover(bridge, checkpoint) -> Progress: + """Rebuild a transfer's ``Progress`` from a saved checkpoint — reads only, never signs. + + Accepts a ``Checkpoint``, its dict, or its JSON. Re-runs ``prepare`` on the + saved intent against the LIVE registry, then checks the route id and registry + version (``CheckpointInvalidError`` / ``RegistryVersionMismatchError``). + Aleo source: a proved-but-unbroadcast transaction yields ``next == "resume"`` + with no network read; a submitted one gets exactly one ``get_status`` from + ``SOURCE_CONFIRMING``. Solana: validates the blockhash pair and reads the + signature status. EVM: delegates to ``bridge.eth.recover_source`` (log + scan); inbound xReserve additionally restores a submitted or prepared + private mint. The result's ``next`` tells the caller what to do. + """ + cp = _coerce_checkpoint(checkpoint) + if cp.version != 1 or not cp.intent or not cp.route: + raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") + plan = _plan_from_intent(bridge.registry, cp.intent) + if cp.route.get("registryVersion") != plan.registry_version: + raise RegistryVersionMismatchError( + f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " + f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") + if cp.route.get("id") != plan.route_id: + raise CheckpointInvalidError( + f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") + resolved = resolve_route(bridge.registry, plan) + src, dst = resolved.source_chain, resolved.destination_chain + source = cp.source or {} + dv = cp.delivery_verification or {} + verification = ({"destinationBalanceBeforeAtomic": dv["balanceBeforeAtomic"], + "expectedDestinationIncreaseAtomic": dv["expectedIncreaseAtomic"]} if dv else {}) + approvals = source.get("approvalTransactionIds") or [] + + if src.family == "aleo": + prepared = source.get("preparedTransaction") + if prepared and not source.get("transactionId"): + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for a prepared Aleo source route") + tx_id = _assert_prepared_id(prepared.get("serializedTransaction"), str(prepared.get("transactionId"))) + return to_progress(plan, Receipt( + id=tx_id, protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared["serializedTransaction"], + **verification})) + tx_id = source.get("transactionId") + if not tx_id: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for an Aleo source route") + receipt = get_status(bridge, plan, Receipt( + id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": plan.route_id, **verification})) + return _finish(bridge, plan, receipt, cp.id) + + if src.family == "solana": + tx_id = source.get("transactionId") + if not tx_id: + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + if cp.destination or approvals: + raise CheckpointInvalidError( + "Bridge checkpoint contains transactions that are invalid for a Solana source route") + blockhash, last_valid = source.get("blockhash"), source.get("lastValidBlockHeight") + if (blockhash is not None or last_valid is not None) and ( + not isinstance(blockhash, str) or not blockhash + or not isinstance(last_valid, str) or not last_valid.isdigit()): + raise CheckpointInvalidError("Bridge checkpoint contains an invalid Solana blockhash lifetime") + state: dict[str, Any] = {"routeId": plan.route_id} + if isinstance(blockhash, str) and isinstance(last_valid, str): + state.update(blockhash=blockhash, lastValidBlockHeight=last_valid) + receipt = get_status(bridge, plan, Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, protocol_state=state)) + return _finish(bridge, plan, receipt, cp.id) + + if resolved.route.protocol == "hyperlane" and src.family == "evm": + if cp.destination: + raise CheckpointInvalidError( + "Bridge checkpoint contains a destination transaction that is invalid for this Hyperlane route") + receipt = _module(bridge, "eth").recover_source(plan, cp, required=False) + return _finish(bridge, plan, receipt, cp.id) + + if resolved.route.protocol != "xreserve" or src.family != "evm" or dst.family != "aleo": + raise UnsupportedRouteError("Bridge checkpoint recovery is not implemented for this route") + + receipt = _module(bridge, "eth").recover_source(plan, cp, required=False) + destination = cp.destination or {} + prepared_dest = destination.get("preparedTransaction") + if prepared_dest and destination.get("transactionId"): + raise CheckpointInvalidError( + "Bridge checkpoint cannot contain both prepared and submitted destination transactions") + if prepared_dest: + _assert_prepared_id(prepared_dest.get("serializedTransaction"), str(prepared_dest.get("transactionId")), + "prepared Aleo destination transaction") + if destination.get("transactionId"): + receipt = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=destination["transactionId"]) + if receipt.status in (Status.ATTESTATION_PENDING, Status.DESTINATION_CONFIRMING): + receipt = get_status(bridge, plan, receipt) + if prepared_dest: + if receipt.status is not Status.DESTINATION_ACTION_REQUIRED: + raise CheckpointInvalidError( + "Prepared destination transaction is no longer valid for the recovered bridge state") + receipt = receipt.replace(id=str(prepared_dest["transactionId"]), + protocol_state={**receipt.protocol_state, + "preparedDestinationTransaction": prepared_dest["serializedTransaction"]}) + return _finish(bridge, plan, receipt, cp.id) diff --git a/bridge-sdk/tests/test_recover.py b/bridge-sdk/tests/test_recover.py new file mode 100644 index 00000000..13cb1f2c --- /dev/null +++ b/bridge-sdk/tests/test_recover.py @@ -0,0 +1,189 @@ +import json + +import pytest + +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint +from aleo_bridge.errors import (CheckpointInvalidError, RegistryVersionMismatchError, UnsupportedRouteError) +from aleo_bridge.lifecycle import prepare, recover +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge + +EVM1 = EVM_ADDRESS + + +def _aleo_eth_checkpoint(b, **source): + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM1) + return plan, {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": plan.amount, "recipient": plan.recipient}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": source} + + +def test_version_route_and_registry_checks(): + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1x") + with pytest.raises(CheckpointInvalidError, match="version"): + recover(b, {**cp, "version": 2}) + with pytest.raises(CheckpointInvalidError, match="does not match"): + recover(b, {**cp, "route": {**cp["route"], "id": "hyperlane:aleo/wbtc->ethereum/wbtc"}}) + with pytest.raises(RegistryVersionMismatchError): + recover(b, {**cp, "route": {**cp["route"], "registryVersion": "2020-01-01.old"}}) + with pytest.raises(CheckpointInvalidError, match="intent"): + recover(b, {**cp, "intent": {"source": {"chain": "aleo"}}}) + + +def test_prepared_but_unbroadcast_aleo_transaction_resumes_without_network(): + b = FakeBridge(ethereum=False) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + plan, cp = _aleo_eth_checkpoint(b, preparedTransaction={"transactionId": "at1prepared", + "serializedTransaction": serialized}) + cp["deliveryVerification"] = {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + progress = recover(b, json.dumps(cp)) # JSON string accepted + assert progress.next == "resume" and progress.receipt.status is Status.SOURCE_SUBMISSION_PENDING + assert progress.receipt.id == "at1prepared" + assert progress.receipt.protocol_state == {"routeId": plan.route_id, "preparedTransaction": serialized, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"} + assert b.calls == [] and b.events == [] + with pytest.raises(CheckpointInvalidError, match="id does not match"): + recover(b, {**cp, "source": {"preparedTransaction": {"transactionId": "at1other", "serializedTransaction": serialized}}}) + with pytest.raises(CheckpointInvalidError, match="invalid prepared"): + recover(b, {**cp, "source": {"preparedTransaction": {"transactionId": "at1prepared", "serializedTransaction": "{not json"}}}) + with pytest.raises(CheckpointInvalidError, match="invalid for a prepared Aleo"): + recover(b, {**cp, "destination": {"transactionId": "at1d"}}) + + +def test_submitted_aleo_source_is_observed_once_never_rebroadcast(): + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + progress = recover(b, Checkpoint.from_dict(cp)) + assert progress.next == "wait" and progress.receipt.status is Status.SOURCE_CONFIRMING + b.aleo.confirmed_transactions["at1burn"] = {"status": "accepted"} + progress = recover(b, cp) + assert progress.next == "wait" and progress.receipt.status is Status.DELIVERY_PENDING + assert progress.receipt.source_tx_id == "at1burn" and b.submitted == [] + # Checkpoint.from_dict (which we do not modify) derives receiptId from source/destination when + # absent, and raises its own "no submitted or prepared transaction" error before recover() ever + # sees an empty source — so a receiptId must be supplied for recover()'s OWN check to be reached. + with pytest.raises(CheckpointInvalidError, match="no submitted source transaction"): + recover(b, {**cp, "receiptId": "at1burn", "source": {}}) + with pytest.raises(CheckpointInvalidError, match="invalid for an Aleo"): + recover(b, {**cp, "source": {"transactionId": "at1burn", "approvalTransactionIds": ["0x1"]}}) + + +def test_solana_checkpoint_validates_blockhash_pair_and_reads_status(): + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + cp = create_checkpoint(plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhash": "recent", + "lastValidBlockHeight": "123456789"}), b.registry) + b.sol.source_status_result = Receipt(id="sig", protocol="hyperlane", status=Status.EXPIRED, source_tx_id="sig", + protocol_state={"routeId": plan.route_id, "blockhashExpired": True, + "sourceError": "Solana transaction expired before confirmation: sig"}) + progress = recover(b, cp) + assert progress.next == "failed" and progress.receipt.status is Status.EXPIRED + assert progress.error == "Solana transaction expired before confirmation: sig" + assert b.calls == [("sol.source_status", Status.SOURCE_CONFIRMING)] + d = cp.to_dict() + with pytest.raises(CheckpointInvalidError, match="blockhash"): + recover(b, {**d, "source": {"transactionId": "sig", "blockhash": "recent"}}) + with pytest.raises(CheckpointInvalidError, match="no submitted source transaction"): + recover(b, {**d, "source": {"blockhash": "recent", "lastValidBlockHeight": "1"}}) + + +def test_evm_hyperlane_delegates_to_eth_recover_source(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", recipient=ALEO_RECIPIENT, + sender=EVM1) + cp = create_checkpoint(plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}), + b.registry) + b.eth.recover_result = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM1}) + progress = recover(b, cp) + assert progress.next == "resume" and b.calls[0][0] == "eth.recover_source" and b.calls[0][2] is False + with pytest.raises(CheckpointInvalidError, match="destination transaction"): + recover(b, {**cp.to_dict(), "destination": {"transactionId": "at1x"}}) + + +def test_evm_xreserve_recovery_paths(): + b = FakeBridge(environment="testnet") + plan = prepare(b.registry, source="sepolia/usdc", destination="aleo-testnet/usdcx", amount="2", + recipient=ALEO_RECIPIENT, mint_mode="private", sender=EVM1) + base = create_checkpoint(plan, Receipt(id="0x" + "22" * 32, protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="0x" + "22" * 32, protocol_state={"routeId": plan.route_id}), + b.registry).to_dict() + attested = Receipt(id="0x" + "cc" * 32, protocol="xreserve", status=Status.ATTESTATION_PENDING, source_tx_id="0x" + "22" * 32, + protocol_state={"routeId": plan.route_id, "mintMode": "private", "intendedRecipient": ALEO_RECIPIENT, + "messageHash": "0x" + "cc" * 32, "nonce": "0x" + "dd" * 32, + "bridgeProgram": "test_usdcx_bridge_v2.aleo"}) + b.eth.recover_result = attested + # ATTESTATION_PENDING → one get_status (Circle not ready) → wait + assert recover(b, base).next == "wait" + assert [c[0] for c in b.calls] == ["eth.recover_source", "xreserve.is_delivered", "xreserve.get_attestation"] + # submitted destination tx → DESTINATION_CONFIRMING, observed once + b.calls.clear() + b.aleo.confirmed_transactions["at1private"] = {"status": "accepted"} + done = recover(b, {**base, "destination": {"transactionId": "at1private"}}) + assert done.next == "done" and done.receipt.destination_tx_id == "at1private" + # both prepared and submitted destination → invalid + serialized = json.dumps({"type": "execute", "id": "at1mint", "fee": {}}) + with pytest.raises(CheckpointInvalidError, match="both prepared and submitted"): + recover(b, {**base, "destination": {"transactionId": "at1private", + "preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + # prepared destination survives only while DESTINATION_ACTION_REQUIRED + b.xreserve.delivered_nonces.clear() + with pytest.raises(CheckpointInvalidError, match="no longer valid"): + recover(b, {**base, "destination": {"preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + from aleo_bridge.types import Attestation + b.xreserve.attestations["0x" + "cc" * 32] = Attestation(b"\x00" * 305, bytes.fromhex("cc" * 32), b"\x11" * 65, "complete") + ready = recover(b, {**base, "destination": {"preparedTransaction": {"transactionId": "at1mint", "serializedTransaction": serialized}}}) + assert ready.next == "complete" and ready.receipt.id == "at1mint" + assert ready.receipt.protocol_state["preparedDestinationTransaction"] == serialized + + +def test_unsupported_route_and_terminal_cleanup(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + store.save(Checkpoint.from_dict(cp)) + b.aleo.confirmed_transactions["at1burn"] = {"status": "rejected"} + progress = recover(b, cp) + assert progress.next == "failed" and store.list() == [] + burn = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM1) + ok = recover(b, create_checkpoint(burn, Receipt(id="at1b", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1b", protocol_state={"routeId": burn.route_id}), b.registry)) + assert ok.next == "wait" + + +def test_recovered_plan_round_trips_through_checkpoint(): + # Controller ruling (task-7-controller-notes.md #1): _plan_from_intent rebuilds the plan via + # prepare(), which is proven field-identical to build_plan for every active route + # (tests/test_prepare.py::test_prepare_equals_build_plan_for_every_active_route). Confirm the + # round trip (create_checkpoint -> to_dict -> recover's internal from_dict/_plan_from_intent) + # holds for an EVM, a Solana and an Aleo-origin route by checking recover()'s Progress.plan. + b = FakeBridge(solana=True) + + evm_plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, sender=EVM1) + evm_cp = create_checkpoint(evm_plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": evm_plan.route_id, + "approvalTxIds": ["0x" + "11" * 32]}), b.registry) + b.eth.recover_result = Receipt(id="0x" + "11" * 32, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": evm_plan.route_id, "approvalTxIds": ["0x" + "11" * 32]}) + assert recover(b, evm_cp).plan == evm_plan + + sol_plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sol_cp = create_checkpoint(sol_plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": sol_plan.route_id}), + b.registry) + assert recover(b, sol_cp).plan == sol_plan + + aleo_plan, aleo_cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + assert recover(b, aleo_cp).plan == aleo_plan From 5b5ca4782cca2427adfc3c51602c5c14e7237f48 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:27:45 -0400 Subject: [PATCH 77/94] fix(bridge-sdk): let get_status see a failing destination-balance read 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). --- bridge-sdk/python/aleo_bridge/lifecycle.py | 35 +++++++++++++--------- bridge-sdk/tests/test_get_status.py | 24 +++++++++++++-- bridge-sdk/tests/test_wait.py | 21 +++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 68a1e377..25be306a 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -330,13 +330,16 @@ def _connected_aleo_address(bridge) -> str | None: def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> int | None: - """The recipient's destination balance, or None when we cannot read it. + """The recipient's destination balance, or None when there is no reader for it. Only read when the destination connection IS the recipient (there is no per-address balance - read in the module contracts); otherwise omit the delivery-verification pair rather than - baseline the wrong account. The RPC read itself is best-effort (review item 8): a transient - failure here is only ever used as an advisory baseline (``execute``'s pre-broadcast checkpoint) - or re-attempted by ``get_status``/``wait`` — it must never raise and block funds movement. + read in the module contracts); otherwise return None rather than baseline the wrong account. + + A transport failure is NOT swallowed here (Task 6 review item 8): ``get_status`` branch 6 uses + this balance as the delivery SIGNAL, and a swallowed RPC error would read as "not delivered + yet" forever instead of being retried by ``wait``'s transient classifier. The one caller that + genuinely cannot afford to raise — ``execute``'s advisory pre-broadcast baseline — does the + swallowing itself, in :func:`_delivery_verification`. """ chain, asset = resolved.destination_chain, resolved.destination_asset if chain.family == "evm": @@ -345,24 +348,28 @@ def _read_destination_balance(bridge, plan: Plan, resolved: ResolvedRoute) -> in or conn.address.lower() != plan.recipient.lower() or asset.locator is None or asset.locator.kind not in ("native", "evm-contract")): return None - try: - return int(bridge.eth.balance(asset.id)) - except Exception: # noqa: BLE001 — advisory read only - return None + return int(bridge.eth.balance(asset.id)) if chain.family == "solana": conn = getattr(bridge, "solana", None) if (conn is None or conn.address != plan.recipient or asset.locator is None or asset.locator.kind != "native"): return None - try: - return int(bridge.sol.balance()) - except Exception: # noqa: BLE001 — advisory read only - return None + return int(bridge.sol.balance()) return None # Aleo private records / token mappings: protocol signal instead def _delivery_verification(bridge, plan: Plan, resolved: ResolvedRoute) -> dict[str, str]: - before = _read_destination_balance(bridge, plan, resolved) + """``execute``'s advisory delivery baseline — an unreadable balance is simply omitted. + + The best-effort swallow lives at THIS call site and not inside ``_read_destination_balance`` + (Task 6 review item 8): here the balance is a nice-to-have baseline written into a checkpoint + before broadcast, so a flaky RPC must never block funds movement; in ``get_status`` branch 6 + the same read is the delivery signal and must raise. + """ + try: + before = _read_destination_balance(bridge, plan, resolved) + except Exception: # noqa: BLE001 — advisory read only + return {} if before is None: return {} expected = parse_decimal_amount(plan.amount, resolved.destination_asset.decimals) diff --git a/bridge-sdk/tests/test_get_status.py b/bridge-sdk/tests/test_get_status.py index ba385294..fb847ae6 100644 --- a/bridge-sdk/tests/test_get_status.py +++ b/bridge-sdk/tests/test_get_status.py @@ -2,8 +2,8 @@ from aleo_bridge.encoding import (xreserve_deposit_payload, xreserve_hook_data, xreserve_message_hash, xreserve_nonce_from_payload) -from aleo_bridge.errors import (CheckpointInvalidError, ConfigurationError, DeliveryUnknownError, - UnsupportedRouteError) +from aleo_bridge.errors import (BridgeError, CheckpointInvalidError, ConfigurationError, + DeliveryUnknownError, UnsupportedRouteError) from aleo_bridge.lifecycle import aleo_transaction_status, get_status, prepare from aleo_bridge.types import Attestation, Receipt, Status from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge @@ -162,6 +162,26 @@ def test_branch6_aleo_origin_balance_diff_fallback(): get_status(b2, plan, receipt) +def test_branch6_a_failing_destination_balance_read_propagates(): + """Carried from the Task 6 review (item 8): in branch 6 the destination balance is the DELIVERY + SIGNAL, not an advisory baseline. A transport failure must surface here so ``wait``'s transient + classifier can retry it — swallowing it would read as "not delivered yet" forever.""" + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="aleo/sol", destination="solana/sol", amount="0.000000001", + recipient=SOL_ADDRESS) + receipt = Receipt(id="at1source", protocol="hyperlane", status=Status.DELIVERY_PENDING, + source_tx_id="at1source", + protocol_state={"routeId": plan.route_id, "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + + def boom(): + raise BridgeError("Solana RPC request failed with HTTP status 429") + + b.sol.balance = boom + with pytest.raises(BridgeError, match="429"): + get_status(b, plan, receipt) + + def test_branch8_and_9_xreserve_outbound_and_not_implemented(): b = FakeBridge(ethereum=False) plan = prepare(b.registry, source="aleo/usdcx", destination="ethereum/usdc", amount="2.1", recipient=EVM_ADDRESS) diff --git a/bridge-sdk/tests/test_wait.py b/bridge-sdk/tests/test_wait.py index 1437b7f8..2cc3f702 100644 --- a/bridge-sdk/tests/test_wait.py +++ b/bridge-sdk/tests/test_wait.py @@ -155,6 +155,27 @@ def always_flaky(bridge, plan_, receipt): assert calls["n"] == 6 # five tolerated, the sixth re-raises +def test_a_flaky_destination_balance_reader_is_retried_not_swallowed(monkeypatch): + """Task 6 review item 8, end to end: the branch-6 balance read raises a 429-shaped transport + error through the REAL ``get_status``, and ``wait`` retries it until the delivery is visible.""" + b = FakeBridge(solana=True) + plan, progress = _sol_progress(b) + monkeypatch.setattr("aleo_bridge.lifecycle.time.sleep", lambda s: None) + calls = {"n": 0} + + def flaky(): + calls["n"] += 1 + if calls["n"] <= 2: + raise BridgeError("Solana RPC request failed with HTTP status 429") + return 101 + + b.sol.balance = flaky + errors = [] + out = wait(b, progress, poll_seconds=0, timeout_seconds=10, on_error=errors.append) + assert calls["n"] == 3 and len(errors) == 2 + assert out.next == "done" and out.receipt.status is Status.COMPLETED + + def test_a_non_transient_error_propagates_on_the_first_attempt(monkeypatch): b = FakeBridge(solana=True) plan, progress = _sol_progress(b) From ec0038af5669fbde6da19d923ea3f42841d787ef Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:31:56 -0400 Subject: [PATCH 78/94] feat(bridge-sdk): resume() and complete() - idempotent rebroadcast, private 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 243 ++++++++++++++- bridge-sdk/tests/test_resume_complete.py | 346 +++++++++++++++++++++ 2 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 bridge-sdk/tests/test_resume_complete.py diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 25be306a..1ba69707 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -20,23 +20,26 @@ from typing import Any, Callable from . import _sealevel +from ._calls import is_duplicate_submission from ._plan import build_plan from .checkpoint import Checkpoint, create_checkpoint from .errors import ( + AttestationError, BridgeError, CheckpointInvalidError, ConfigurationError, DeliveryUnknownError, InvalidAmountError, InvalidRecipientError, + NotResumableError, PollingTimeoutError, RegistryVersionMismatchError, RouteUnavailableError, UnsupportedRouteError, ) from .registry import Asset, Chain, Registry, Route -from .types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Fee, Plan, - Progress, Quote, Receipt, Status, to_progress) +from .types import (CALLER_BOUNDARIES, TERMINAL, AleoHyperlaneQuote, AleoXReserveQuote, Attestation, + Fee, Plan, Progress, Quote, Receipt, Status, to_progress) from .units import format_decimal_amount, parse_decimal_amount, resolve_amount MINT_MODES = ("public", "record", "private") @@ -860,8 +863,9 @@ def wait(bridge, progress: Progress, *, until=None, poll_seconds: float = 15.0, time.sleep(interval) -__all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "execute", "get_status", "prepare", - "quote", "recover", "resolve_route", "wait"] +__all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "complete", "execute", "get_status", + "is_duplicate_broadcast_error", "prepare", "quote", "recover", "resolve_route", "resume", + "submit_serialized", "wait"] # ── recover ─────────────────────────────────────────────────────────────────── @@ -1026,3 +1030,234 @@ def recover(bridge, checkpoint) -> Progress: protocol_state={**receipt.protocol_state, "preparedDestinationTransaction": prepared_dest["serializedTransaction"]}) return _finish(bridge, plan, receipt, cp.id) + + +# ── Idempotent Aleo rebroadcast (invariant 3) ───────────────────────────────── + +#: Plan 1's duplicate-broadcast classifier, re-exported under the lifecycle's own name. +#: +#: It is deliberately NOT reimplemented here: ``AleoCall.submit_prepared`` already applies exactly +#: this rule to every prepared broadcast, and two rules that could ever disagree about "is this a +#: duplicate?" is one rule too many for a funds-critical path. Only the node's *"already exists"* +#: answer (ledger or mempool) means "this exact transaction is already known"; ``duplicate serial +#: number`` / ``duplicate output id`` mean a DIFFERENT transaction collided with this one's records +#: and must surface as failures — and they do, because they never say "already exists". +is_duplicate_broadcast_error = is_duplicate_submission + + +def submit_serialized(bridge, serialized: str, expected_id: str) -> str: + """Broadcast an already-proved transaction; a duplicate answer is success, not a failure. + + Returns the transaction id the node acknowledged, which must be *expected_id* — the id of the + exact bytes that were broadcast. A node that answers with a different id has accepted something + this transfer never checkpointed, so it is refused rather than recorded as its transaction. + """ + try: + submitted = str(bridge.aleo.network.submit_transaction(serialized)).strip().strip('"') + except Exception as exc: # noqa: BLE001 — the node's error type varies by transport + if is_duplicate_broadcast_error(exc): + return expected_id # the earlier broadcast won the race: nothing left to do + raise + if submitted != expected_id: + raise CheckpointInvalidError( + f"Aleo node acknowledged transaction {submitted}; expected {expected_id}. The prepared " + "bytes and the node's answer disagree — do not resend; inspect both ids first.") + return submitted + + +# ── resume ──────────────────────────────────────────────────────────────────── + +def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, + secret_nonce: str | None = None, poll_seconds: float = 1.0, timeout_seconds: float = 120.0, + proving: str = "delegate") -> Progress: + """Finish the source leg an interruption left unsubmitted — never repeats an irreversible step. + + Requires ``progress.next == "resume"`` (status ``SOURCE_SUBMISSION_PENDING``); anything else is + a :class:`~aleo_bridge.errors.NotResumableError` pointing at ``wait``/``recover``. + + Aleo source: rebroadcasts the checkpointed transaction byte-for-byte, after checking that the + serialized payload's own id matches the saved one — a duplicate-transaction answer means the + first broadcast won the race and counts as success. The bytes are then dropped from the + receipt. Nothing is re-proved, so the transfer can only ever exist once on chain. + + EVM source: re-scans source history from the confirmed approval + (``bridge.eth.recover_source(plan, checkpoint, required=True)``) and, only when that scan proves + no deposit/dispatch exists yet, re-quotes and authorizes the single remaining transaction + through the module's own ``plan=`` surface. Two guards ported from veil refuse rather than + guess: the re-quoted hook data must equal the hook the checkpointed approval committed to (so a + private mint can never be re-hooked to a commitment its recipient cannot open), and the + allowance must still cover the deposit (a vanished allowance means something else spent it, and + re-approving is a second irreversible step ``resume`` does not own). A confirmed approval is + never repeated; the ids already recorded are carried into the new receipt. + + Solana source: ``SolCall`` has no approval step and no pre-broadcast state to continue, so there + is nothing to resume — ``recover``/``wait`` observe the signature instead. + + ``secret_nonce`` is mandatory when ``plan.mint_mode == "private"``, and is checked before any + RPC: the SDK never stored it, and a silent ``"0scalar"`` fallback would commit the deposit to a + hook nobody can open. ``proving`` is accepted for symmetry with ``execute``/``complete`` and is + never used — no resume path ever proves anything: an Aleo leg rebroadcasts bytes that were + already proved, and an EVM leg has no proofs at all. + """ + plan, receipt = progress.plan, progress.receipt + if progress.next != "resume" or receipt.status is not Status.SOURCE_SUBMISSION_PENDING: + raise NotResumableError( + "Bridge progress has no source submission to resume (next must be 'resume' at " + "SOURCE_SUBMISSION_PENDING); call wait() or recover() to refresh it") + resolved = resolve_route(bridge.registry, plan) + _require_active(resolved.route) + _check_receipt(plan, receipt) + emit = _Emitter(bridge, plan, on_checkpoint) + state = receipt.protocol_state + family = resolved.source_chain.family + + if family == "aleo": + serialized = state.get("preparedTransaction") + if not isinstance(serialized, str) or not serialized: + raise NotResumableError( + "Prepared Aleo transfer is missing its serialized transaction: resume() rebroadcasts " + "the exact proved bytes and never re-proves. Recover from the checkpoint written " + "between proving and broadcast, or start the transfer over if none exists.") + tx_id = _assert_prepared_id(serialized, receipt.id) + submit_serialized(bridge, serialized, tx_id) + new_state: dict[str, Any] = {"routeId": plan.route_id} + for key in ("destinationBalanceBeforeAtomic", "expectedDestinationIncreaseAtomic"): + if isinstance(state.get(key), str): + new_state[key] = state[key] + submitted = Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, + source_tx_id=tx_id, protocol_state=new_state) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + if family == "solana": + raise NotResumableError( + "Solana source legs have no resumable state: the transfer is signed and broadcast in one " + "step, so nothing is ever left to submit. Call recover() or wait() to observe the " + "signature instead.") + + if family != "evm": + raise UnsupportedRouteError(f"Source resumption is not implemented for {resolved.source_chain.id}") + + eth = _module(bridge, "eth") + _assert_sender(plan, bridge.ethereum.address, family="evm") + is_xreserve = resolved.route.protocol == "xreserve" + nonce = _mint_secret(plan, secret_nonce) if is_xreserve else None # before any RPC + + recovered = eth.recover_source(plan, create_checkpoint(plan, receipt, bridge.registry), required=True) + if recovered.status is not Status.SOURCE_SUBMISSION_PENDING: + emit(recovered) # history already holds the irreversible step + emit.finalize() + return to_progress(plan, recovered) + + if is_xreserve: + quoted = eth.quote_deposit_usdc(plan=plan, secret_nonce=nonce) + saved_hook = state.get("hookData") + if isinstance(saved_hook, str) and saved_hook.lower() != ("0x" + quoted.hook_data.hex()).lower(): + raise NotResumableError( + "The re-quoted hook data does not match the hook this transfer's approval committed " + "to: the secret nonce differs from the one used at execute(). Pass that same " + "secret_nonce — depositing under another hook mints to a commitment the recipient " + "can never open.") + else: + quoted = eth.quote_transfer_remote(plan=plan) + if quoted.approval_required: + raise NotResumableError( + "The approval recorded for this transfer no longer covers it: its allowance is gone. " + "Inspect Ethereum source history before starting another transfer — resume() will not " + "issue a second approval.") + + call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) if is_xreserve else eth.transfer_remote(plan=plan) + result = call.send(wait=True, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, + on_checkpoint=emit) + submitted = result.receipt + prior = [a for a in (state.get("approvalTxIds") or []) if isinstance(a, str)] + approvals = prior + [a for a in (submitted.protocol_state.get("approvalTxIds") or []) if a not in prior] + if approvals != list(submitted.protocol_state.get("approvalTxIds") or []): + submitted = submitted.replace(protocol_state={**submitted.protocol_state, "approvalTxIds": approvals}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + +# ── complete ────────────────────────────────────────────────────────────────── + +def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, + on_checkpoint: Callable | None = None, proving: str = "delegate") -> Progress: + """Submit the one user-signed Aleo transaction a private USDCx mint needs. + + Requires ``progress.next == "complete"`` — Circle has attested the deposit and the receipt + carries ``next_action == {"kind": "xreserve-private-mint", "chainId": }``. The + persisted payload (305 bytes), message hash (32 bytes) and attestation hex are re-validated + first, then one of two paths runs: + + * a ``preparedDestinationTransaction`` left by an earlier interrupted attempt is rebroadcast + byte-for-byte (a duplicate answer is success, and no ``secret_nonce`` is needed — those bytes + are already proved), or + * ``bridge.xreserve.private_mint`` builds the mint — re-verifying on the way that + ``(recipient, secret_nonce)`` really opens the attested hook-data commitment, so a wrong nonce + never reaches proving — which is then proved, checkpointed BEFORE broadcast, and broadcast. + + The source deposit is never repeated, and the secret nonce, the attestation and the hook data + are never written to a receipt, a checkpoint or a ``Progress``. ``secret_nonce`` must be the + value used at ``execute``; it is required for a private plan on the proving path + (``ConfigurationError``, raised before any RPC). + """ + if progress.next != "complete": + raise NotResumableError( + "Bridge progress has no destination action to complete (next must be 'complete'); call " + "wait() to refresh it — the Circle attestation may still be pending") + plan, receipt = progress.plan, progress.receipt + # Deliberately no _require_active here (unlike resume): by the time a transfer reaches + # DESTINATION_ACTION_REQUIRED the USDC is already deposited on Ethereum, and refusing the mint + # because the registry has since parked the route would strand it. The proving path still hits + # XReserveModule's own availability check; a rebroadcast of already-proved bytes needs none. + resolved = resolve_route(bridge.registry, plan) + _check_receipt(plan, receipt) + action = receipt.next_action or {} + if (receipt.status is not Status.DESTINATION_ACTION_REQUIRED + or action.get("kind") != "xreserve-private-mint" + or action.get("chainId") != resolved.destination_chain.id): + raise NotResumableError( + "Bridge receipt carries no supported destination action: complete() only finishes an " + "xReserve private mint, on this transfer's own destination chain") + if (resolved.route.protocol != "xreserve" or resolved.source_chain.family != "evm" + or resolved.destination_chain.family != "aleo"): + raise UnsupportedRouteError("Destination completion is not implemented for this bridge route") + + state = receipt.protocol_state + payload = _hex_bytes(state.get("payload"), length=305) + message_hash = _hex_bytes(state.get("messageHash"), length=32) + attestation = _hex_bytes(state.get("attestation")) + if payload is None or message_hash is None or not attestation: + raise AttestationError( + "Ready xReserve receipt is missing its validated Circle attestation (a 305-byte payload, " + "a 32-byte messageHash and the attestation hex); refresh it with wait()") + emit = _Emitter(bridge, plan, on_checkpoint) + + prepared_dest = state.get("preparedDestinationTransaction") + if prepared_dest is not None: + tx_id = _assert_prepared_id(prepared_dest, receipt.id, "prepared Aleo destination transaction") + submit_serialized(bridge, prepared_dest, tx_id) + submitted = receipt.replace( + status=Status.DESTINATION_CONFIRMING, destination_tx_id=tx_id, next_action=None, + protocol_state={k: v for k, v in state.items() if k != "preparedDestinationTransaction"}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) + + nonce = _mint_secret(plan, secret_nonce) # before any RPC + att = Attestation(payload=payload, message_hash=message_hash, attestation=attestation, status="complete") + call = bridge.xreserve.private_mint(att, plan.recipient, secret_nonce=nonce, route=resolved.route) + prepared = _prepare_aleo(call, proving) + # invariant 3: the exact bytes live in a checkpoint before the network can ever see them + emit(receipt.replace(id=prepared.transaction_id, + protocol_state={**state, "preparedDestinationTransaction": prepared.serialized})) + call.submit_prepared(prepared, wait=False) # polling is wait()'s job, not complete()'s + submitted = receipt.replace( + status=Status.DESTINATION_CONFIRMING, destination_tx_id=prepared.transaction_id, next_action=None, + protocol_state={**{k: v for k, v in state.items() if k != "preparedDestinationTransaction"}, + "destinationProgram": call.program_id, "destinationFunction": call.function_name}) + emit(submitted) + emit.finalize() + return to_progress(plan, submitted) diff --git a/bridge-sdk/tests/test_resume_complete.py b/bridge-sdk/tests/test_resume_complete.py new file mode 100644 index 00000000..dd899156 --- /dev/null +++ b/bridge-sdk/tests/test_resume_complete.py @@ -0,0 +1,346 @@ +"""``lifecycle.resume`` and ``lifecycle.complete`` — the two caller-boundary verbs. + +``resume`` continues an interrupted SOURCE leg: an Aleo leg rebroadcasts the checkpointed bytes +byte-for-byte (a duplicate answer means the first broadcast won and counts as success), an EVM leg +re-scans source history first and only authorizes the one step history proves is still missing. +``complete`` submits the single user-signed Aleo destination transaction a private USDCx mint +needs, and is idempotent through the same rebroadcast rule. + +The funds-critical invariants under test: neither verb ever repeats an irreversible step, veil's +two resume guards (hook-data commitment, surviving allowance) refuse rather than guess, a private +resume/complete without its ``secret_nonce`` is refused before any RPC, and the secret nonce never +reaches a checkpoint, a protocol_state, or a Progress. +""" +import json + +import pytest +from aleo import AleoNetworkError + +from aleo_bridge._calls import is_duplicate_submission +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import (AttestationError, CheckpointInvalidError, ConfigurationError, + NotResumableError) +from aleo_bridge.lifecycle import (complete, is_duplicate_broadcast_error, prepare, resume, + submit_serialized) +from aleo_bridge.types import Receipt, Status, to_progress +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge +from tests.test_get_status import SIG, _inbound_private + +APPROVAL = "0x" + "11" * 32 + + +def _prepared_progress(b): + """An Aleo-origin transfer proved but never broadcast — exactly what ``execute`` checkpoints + between ``delegate_prepared`` and ``submit_prepared``.""" + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + receipt = Receipt(id="at1prepared", protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": serialized, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"}) + return plan, serialized, to_progress(plan, receipt) + + +def _xreserve_progress(b, **plan_kw): + plan = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, **plan_kw) + receipt = Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": EVM_ADDRESS, + "hookData": "0x" + b.eth.hook_data.hex()}) + return plan, receipt + + +# ── duplicate-broadcast classification (plan 1's rule, not a second one) ────── + +def test_duplicate_detection_is_plan_1s_rule_and_refuses_double_spend_shapes(): + assert is_duplicate_broadcast_error is is_duplicate_submission + assert is_duplicate_broadcast_error(AleoNetworkError("Transaction 'at1x' already exists in the ledger", status=400)) + assert is_duplicate_broadcast_error(AleoNetworkError("transaction at1x already exists in the memory pool")) + # a DIFFERENT transaction colliding with this one's records is a real failure, never success + assert not is_duplicate_broadcast_error(AleoNetworkError("Duplicate serial number found in transaction")) + assert not is_duplicate_broadcast_error(AleoNetworkError("duplicate output id in transaction")) + assert not is_duplicate_broadcast_error(AleoNetworkError("Duplicate transaction at1x")) + assert not is_duplicate_broadcast_error(AleoNetworkError("Invalid transaction: fee verification failed", status=400)) + + +def test_submit_serialized_refuses_an_id_the_node_did_not_echo(): + b = FakeBridge(ethereum=False) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + assert submit_serialized(b, serialized, "at1prepared") == "at1prepared" + b.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1prepared"): + submit_serialized(b, serialized, "at1prepared") + + +# ── resume: Aleo source ─────────────────────────────────────────────────────── + +def test_resume_rebroadcasts_identical_bytes_and_treats_a_duplicate_as_success(): + b = FakeBridge(ethereum=False) + plan, serialized, progress = _prepared_progress(b) + cps = [] + out = resume(b, progress, on_checkpoint=cps.append) + assert b.aleo.submitted == [serialized] # byte-for-byte, never re-proved + assert out.next == "wait" and out.receipt.status is Status.SOURCE_CONFIRMING + assert out.receipt.source_tx_id == "at1prepared" + assert out.receipt.protocol_state == {"routeId": plan.route_id, + "destinationBalanceBeforeAtomic": "100", + "expectedDestinationIncreaseAtomic": "1"} # bytes discarded + assert len(cps) == 1 and cps[0].source == {"transactionId": "at1prepared"} + assert cps[0].delivery_verification == {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "1"} + + # the node says it already knows this transaction: the earlier broadcast won the race + b2 = FakeBridge(ethereum=False) + b2.aleo.duplicate_on_submit = True + assert resume(b2, progress).receipt.status is Status.SOURCE_CONFIRMING + + # any other node rejection is a real failure and propagates + b3 = FakeBridge(ethereum=False) + + def invalid(tx): + raise AleoNetworkError("Invalid transaction: fee verification failed", status=400) + + b3.aleo.network.submit_transaction = invalid + with pytest.raises(AleoNetworkError): + resume(b3, progress) + + # a node that answers with a different id never gets recorded as this transfer's transaction + b4 = FakeBridge(ethereum=False) + b4.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1prepared"): + resume(b4, progress) + + +def test_resume_replaces_the_prepared_checkpoint_in_the_bound_store(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan, serialized, progress = _prepared_progress(b) + from aleo_bridge.checkpoint import create_checkpoint + store.save(create_checkpoint(plan, progress.receipt, b.registry)) + assert store.list()[0].source["preparedTransaction"]["serializedTransaction"] == serialized + resume(b, progress) + saved = store.list() + assert [c.id for c in saved] == ["at1prepared"] + assert saved[0].source == {"transactionId": "at1prepared"} # the unbroadcast bytes are gone + + +def test_resume_refuses_a_wrong_state_a_foreign_route_and_mismatched_bytes(): + b = FakeBridge(ethereum=False) + plan, serialized, progress = _prepared_progress(b) + with pytest.raises(NotResumableError, match="SOURCE_SUBMISSION_PENDING"): + resume(b, to_progress(plan, progress.receipt.replace(status=Status.SOURCE_CONFIRMING))) + with pytest.raises(NotResumableError, match="serialized transaction"): + resume(b, to_progress(plan, progress.receipt.replace(protocol_state={"routeId": plan.route_id}))) + with pytest.raises(CheckpointInvalidError, match="id does not match"): + resume(b, to_progress(plan, progress.receipt.replace(id="at1else"))) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + resume(b, to_progress(plan, progress.receipt.replace( + protocol_state={**progress.receipt.protocol_state, "routeId": "other"}))) + assert b.aleo.submitted == [] + + +def test_resume_refuses_a_solana_source_leg_and_points_at_recover(): + """``SolCall`` has no approval step and no resumable pre-broadcast state: there is nothing to + continue, so resume never guesses — it sends the caller to recover()/wait().""" + b = FakeBridge(solana=True) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + sig = "5igNature" * 8 + receipt = Receipt(id=sig, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id}) + with pytest.raises(NotResumableError, match="recover"): + resume(b, to_progress(plan, receipt)) + assert b.calls == [] + + +# ── resume: EVM source ──────────────────────────────────────────────────────── + +def test_resume_evm_xreserve_rescans_then_deposits_once(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt # history has no deposit → still submission pending + cps = [] + out = resume(b, to_progress(plan, receipt), secret_nonce="7scalar", on_checkpoint=cps.append) + assert [c[0] for c in b.calls] == ["eth.recover_source", "eth.quote_deposit_usdc", "eth.deposit_usdc"] + assert b.calls[0][2] is True # required=True: refuse to guess without an approval block + assert b.calls[1][1] == {"plan": plan, "secret_nonce": "7scalar"} + assert b.calls[2][1] == {"plan": plan, "secret_nonce": "7scalar"} + assert out.receipt.status is Status.ATTESTATION_PENDING + assert out.receipt.protocol_state["approvalTxIds"] == [APPROVAL] # prior approval carried forward + # the secret nonce reaches the module and nothing else + assert "7scalar" not in json.dumps(out.receipt.protocol_state) + assert all("7scalar" not in json.dumps(c.to_dict()) for c in cps) + + +def test_resume_evm_never_repeats_a_deposit_history_already_contains(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt.replace(status=Status.ATTESTATION_PENDING, id="0x" + "cc" * 32) + out = resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert [c[0] for c in b.calls] == ["eth.recover_source"] + assert out.receipt.status is Status.ATTESTATION_PENDING and out.next == "wait" + + +def test_resume_refuses_when_the_re_quoted_hook_data_does_not_match_the_checkpoint(): + """veil guard 1: the hook commits ``(recipient, secret_nonce)``. A deposit built with a + different nonce than the approval was quoted against mints to a commitment nobody can open.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + b.eth.hook_data = bytes([2]) + b"\x99" * 64 + with pytest.raises(NotResumableError, match="secret nonce"): + resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert "eth.deposit_usdc" not in [c[0] for c in b.calls] + + +def test_resume_refuses_when_the_recovered_allowance_is_gone(): + """veil guard 2: the approval this checkpoint recorded no longer covers the deposit — something + else spent it. Re-approving here would be a second irreversible step resume never owns.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + b.eth.approval_required = True + with pytest.raises(NotResumableError, match="allowance"): + resume(b, to_progress(plan, receipt), secret_nonce="7scalar") + assert "eth.deposit_usdc" not in [c[0] for c in b.calls] + + +def test_resume_of_a_private_mint_without_its_secret_nonce_is_refused_before_any_rpc(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + b.eth.recover_result = receipt + with pytest.raises(ConfigurationError, match="secret_nonce"): + resume(b, to_progress(plan, receipt)) + assert b.calls == [] + # a public mint has nothing to commit to: the default is fine + b2 = FakeBridge() + plan2, receipt2 = _xreserve_progress(b2) + b2.eth.recover_result = receipt2 + assert resume(b2, to_progress(plan2, receipt2)).receipt.status is Status.ATTESTATION_PENDING + assert b2.calls[1][1]["secret_nonce"] == "0scalar" + + +def test_resume_evm_hyperlane_redispatches_without_re_approving(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT) + receipt = Receipt(id=APPROVAL, protocol="hyperlane", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": EVM_ADDRESS}) + b.eth.recover_result = receipt + out = resume(b, to_progress(plan, receipt)) + assert [c[0] for c in b.calls] == ["eth.recover_source", "eth.quote_transfer_remote", "eth.transfer_remote"] + assert out.receipt.status is Status.SOURCE_CONFIRMING + assert out.receipt.protocol_state["approvalTxIds"] == [APPROVAL] + b2 = FakeBridge() + b2.eth.recover_result = receipt + b2.eth.approval_required = True + with pytest.raises(NotResumableError, match="allowance"): + resume(b2, to_progress(plan, receipt)) + + +def test_resume_refuses_a_plan_prepared_for_another_account(): + b = FakeBridge() + plan, receipt = _xreserve_progress(b) + stale = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender="0x0000000000000000000000000000000000000009") + with pytest.raises(ConfigurationError, match="sender"): + resume(b, to_progress(stale, receipt.replace(protocol_state={**receipt.protocol_state, + "routeId": stale.route_id}))) + assert b.calls == [] + + +# ── complete ────────────────────────────────────────────────────────────────── + +def _ready(b): + plan, payload, message_hash, receipt = _inbound_private(b) + ready = receipt.replace(status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": "aleo-testnet"}, + protocol_state={**receipt.protocol_state, "attestation": SIG}) + return plan, payload, message_hash, ready + + +def test_complete_proves_checkpoints_then_submits_one_private_mint(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + cps = [] + out = complete(b, to_progress(plan, ready), secret_nonce="7scalar", on_checkpoint=cps.append) + assert b.calls[-1][0] == "xreserve.private_mint" and b.calls[-1][1]["secret_nonce"] == "7scalar" + assert [e[0] for e in b.events] == ["delegate_prepared", "checkpoint:DESTINATION_ACTION_REQUIRED", + "submit", "checkpoint:DESTINATION_CONFIRMING"] + serialized = json.dumps({"type": "execute", "id": "at1fake1", "fee": {}}) + assert cps[0].destination == {"preparedTransaction": {"transactionId": "at1fake1", + "serializedTransaction": serialized}} + assert cps[0].source == {"transactionId": "0x" + "22" * 32} + assert cps[1].destination == {"transactionId": "at1fake1"} + assert out.next == "wait" and out.receipt.status is Status.DESTINATION_CONFIRMING + assert out.receipt.destination_tx_id == "at1fake1" and out.receipt.next_action is None + assert out.receipt.protocol_state["payload"] == "0x" + payload.hex() + assert out.receipt.protocol_state["destinationFunction"] == "private_mint" + assert "preparedDestinationTransaction" not in out.receipt.protocol_state + # the nonce, the attestation and the hook never reach the recovery record + for cp in cps: + text = json.dumps(cp.to_dict()) + assert "7scalar" not in text and SIG not in text and "attestation" not in text + assert "7scalar" not in json.dumps(out.receipt.protocol_state) + assert "secretNonce" not in json.dumps(out.receipt.protocol_state) + + +def test_complete_never_reaches_proving_with_a_nonce_that_opens_no_commitment(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + with pytest.raises(AttestationError): + complete(b, to_progress(plan, ready), secret_nonce="8scalar") + assert b.events == [] and b.submitted == [] + + +def test_complete_of_a_private_mint_without_its_secret_nonce_is_refused_before_any_rpc(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + with pytest.raises(ConfigurationError, match="secret_nonce"): + complete(b, to_progress(plan, ready)) + assert b.calls == [] and b.events == [] + + +def test_complete_rebroadcasts_a_prepared_destination_without_reproving(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + serialized = json.dumps({"type": "execute", "id": "at1private", "fee": {}}) + ready = ready.replace(id="at1private", + protocol_state={**ready.protocol_state, "preparedDestinationTransaction": serialized}) + cps = [] + out = complete(b, to_progress(plan, ready), on_checkpoint=cps.append) # no secret nonce needed + assert b.aleo.submitted == [serialized] + assert not any(e[0] == "delegate_prepared" for e in b.events) + assert out.receipt.status is Status.DESTINATION_CONFIRMING and out.receipt.destination_tx_id == "at1private" + assert "preparedDestinationTransaction" not in out.receipt.protocol_state + assert len(cps) == 1 and cps[0].destination == {"transactionId": "at1private"} + # the node already knows it: the earlier broadcast won + b.aleo.duplicate_on_submit = True + assert complete(b, to_progress(plan, ready)).receipt.status is Status.DESTINATION_CONFIRMING + # ...but a mismatched id never becomes this transfer's destination transaction + b2 = FakeBridge(environment="testnet") + b2.aleo.network.submit_transaction = lambda tx: "at1other" + with pytest.raises(CheckpointInvalidError, match="expected at1private"): + complete(b2, to_progress(plan, ready)) + + +def test_complete_guards(): + b = FakeBridge(environment="testnet") + plan, payload, message_hash, ready = _ready(b) + with pytest.raises(NotResumableError, match="complete"): + complete(b, to_progress(plan, ready.replace(status=Status.ATTESTATION_PENDING, next_action=None))) + with pytest.raises(NotResumableError, match="destination action"): + complete(b, to_progress(plan, ready.replace(next_action={"kind": "other", "chainId": "aleo-testnet"}))) + with pytest.raises(AttestationError, match="attestation"): + complete(b, to_progress(plan, ready.replace(protocol_state={**ready.protocol_state, "attestation": "zz"}))) + with pytest.raises(AttestationError): + complete(b, to_progress(plan, ready.replace( + protocol_state={k: v for k, v in ready.protocol_state.items() if k != "payload"}))) + with pytest.raises(CheckpointInvalidError, match="does not match the prepared route"): + complete(b, to_progress(plan, ready.replace( + protocol_state={**ready.protocol_state, "routeId": "other"}))) + assert b.aleo.submitted == [] and b.submitted == [] and b.events == [] From cef3230b9295762a2e4d5d9b0cd9392920436ea1 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:38:57 -0400 Subject: [PATCH 79/94] fix(bridge-sdk): recover() rejects malformed deliveryVerification and cleans up by checkpoint id --- bridge-sdk/python/aleo_bridge/lifecycle.py | 10 ++++-- bridge-sdk/tests/test_recover.py | 41 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 1ba69707..5e9d37b5 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -954,8 +954,14 @@ def recover(bridge, checkpoint) -> Progress: src, dst = resolved.source_chain, resolved.destination_chain source = cp.source or {} dv = cp.delivery_verification or {} - verification = ({"destinationBalanceBeforeAtomic": dv["balanceBeforeAtomic"], - "expectedDestinationIncreaseAtomic": dv["expectedIncreaseAtomic"]} if dv else {}) + if dv: + before, expected = dv.get("balanceBeforeAtomic"), dv.get("expectedIncreaseAtomic") + if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): + raise CheckpointInvalidError( + "Bridge checkpoint contains invalid destination balance verification state") + verification = {"destinationBalanceBeforeAtomic": before, "expectedDestinationIncreaseAtomic": expected} + else: + verification = {} approvals = source.get("approvalTransactionIds") or [] if src.family == "aleo": diff --git a/bridge-sdk/tests/test_recover.py b/bridge-sdk/tests/test_recover.py index 13cb1f2c..44cbdfc8 100644 --- a/bridge-sdk/tests/test_recover.py +++ b/bridge-sdk/tests/test_recover.py @@ -160,6 +160,47 @@ def test_unsupported_route_and_terminal_cleanup(tmp_path): assert ok.next == "wait" +def test_malformed_delivery_verification_raises_checkpoint_invalid(): + # Item 8 (carried from Task 7 review): a hand-edited/foreign checkpoint whose + # deliveryVerification block is missing a key or holds a non-digit value must raise + # CheckpointInvalidError, never KeyError, before any network read. + b = FakeBridge(ethereum=False) + plan, cp = _aleo_eth_checkpoint(b, transactionId="at1burn") + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": "100"}}) # missing key + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": "100", "expectedIncreaseAtomic": "abc"}}) + with pytest.raises(CheckpointInvalidError, match="destination balance verification"): + recover(b, {**cp, "deliveryVerification": {"balanceBeforeAtomic": None, "expectedIncreaseAtomic": "1"}}) + assert b.calls == [] and b.events == [] + + +def test_terminal_cleanup_deletes_by_checkpoint_id_not_receipt_id(tmp_path): + # Item 7 (carried from Task 7 review): a Solana checkpoint whose stored id (the source + # signature) differs from the id the refreshed receipt ends up carrying (EXPIRED status + # can flip the receipt id to a message id). _finish must delete the record keyed on + # cp.id ("sig"), never one keyed on receipt.id ("msg-divergent"). + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + plan = prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + cp = create_checkpoint(plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": plan.route_id, + "blockhash": "recent", + "lastValidBlockHeight": "123456789"}), + b.registry) + assert cp.id == "sig" + store.save(cp) + b.sol.source_status_result = Receipt(id="msg-divergent", protocol="hyperlane", status=Status.EXPIRED, + source_tx_id="sig", + protocol_state={"routeId": plan.route_id, + "sourceError": "Solana transaction expired before confirmation: sig"}) + progress = recover(b, cp) + assert progress.next == "failed" and progress.receipt.id == "msg-divergent" + assert store.load("sig") is None + assert store.load("msg-divergent") is None # nothing was ever stored under this key to begin with + + def test_recovered_plan_round_trips_through_checkpoint(): # Controller ruling (task-7-controller-notes.md #1): _plan_from_intent rebuilds the plan via # prepare(), which is proven field-identical to build_plan for every active route From 7f15242b63f3b7471ce4dbcb7da993e8cea967e7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:40:50 -0400 Subject: [PATCH 80/94] feat(bridge-sdk): Bridge lifecycle methods, pending(), and additive package exports --- bridge-sdk/python/aleo_bridge/__init__.py | 3 + bridge-sdk/python/aleo_bridge/client.py | 99 +++++++++++++++++++ bridge-sdk/tests/test_client_lifecycle.py | 111 ++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 bridge-sdk/tests/test_client_lifecycle.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 0133354a..ca61c7e7 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -28,6 +28,8 @@ from .eth import Ethereum, EthModule # noqa: E402 from .freezelist import EMPTY_MERKLE_PROOF_PAIR, FreezeList # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 +from . import lifecycle # noqa: E402 +from .lifecycle import prepare # noqa: E402 from .privacy import PrivacyModule # noqa: E402 from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 from .sol import DEFAULT_SOLANA_RPC_URL, Solana, SolModule # noqa: E402 @@ -49,4 +51,5 @@ "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", "EthModule", "Ethereum", "EvmCall", "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", + "lifecycle", "prepare", ] diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 672bcefd..0d417f51 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -14,6 +14,7 @@ import re from typing import TYPE_CHECKING, Any, Callable +from . import lifecycle as _lifecycle from ._calls import AleoCall from .errors import ConfigurationError from .eth import Ethereum, EthModule @@ -287,6 +288,104 @@ def status(self) -> BridgeStatus: return BridgeStatus(environment=self.environment, registry_version=self.registry.version, chains=chains, pending=pending) + # ── Tier 1: the lifecycle ────────────────────────────────────────────── + + def quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: str, + sender: str | None = None, protocol: str | None = None, mint_mode: str = "public", + secret_nonce: str = "0scalar"): + """Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + + ``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` + tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of + ``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is + the destination-chain address. ``mint_mode`` (xReserve into Aleo only): + ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` + — you finish it yourself with ``complete`` and must keep ``secret_nonce``. + Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / + solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with + ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the + user fees + amount before ``execute``. + """ + return _lifecycle.quote(self, source=source, destination=destination, amount=amount, + amount_atomic=amount_atomic, recipient=recipient, sender=sender, + protocol=protocol, mint_mode=mint_mode, secret_nonce=secret_nonce) + + def execute(self, plan, *, on_checkpoint=None, proving: str = "delegate", mode: str | None = None, + record: str | None = None, merkle_proof: str | None = None, + gas_payment_microcredits: int | None = None, secret_nonce: str | None = None, + poll_seconds: float = 1.0, timeout_seconds: float = 120.0): + """Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + + Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to + ``on_checkpoint`` (and the bound store) at every boundary — including + AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is + resumable without proving twice. ``proving`` is ``"delegate"`` (DPS) or + ``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or + ``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default + private; ``record``/``merkle_proof`` optional — the SDK selects a record + and computes the exclusion proof). The Hyperlane hook payment is + re-quoted right before proving unless ``gas_payment_microcredits`` is + pinned. Irreversible once the source step is broadcast: afterwards use + ``wait`` / ``recover``, never ``execute`` again. + """ + return _lifecycle.execute(self, plan, on_checkpoint=on_checkpoint, proving=proving, mode=mode, + record=record, merkle_proof=merkle_proof, + gas_payment_microcredits=gas_payment_microcredits, secret_nonce=secret_nonce, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds) + + def get_status(self, plan, receipt): + """One status refresh (no polling, no signing); returns the same receipt when nothing changed.""" + return _lifecycle.get_status(self, plan, receipt) + + def wait(self, progress, *, until=None, poll_seconds: float = 15.0, timeout_seconds: float = 1200.0, + on_update=None): + """Poll until the transfer finishes or needs you: stops at ``progress.next`` + in resume / complete / done / failed, or at any status in ``until``. + + A ``PollingTimeoutError`` is NOT a failure — the transfer is still in + flight; call ``wait`` again or ``recover`` later. ``on_update`` receives + each changed ``Progress``. + """ + return _lifecycle.wait(self, progress, until=until, poll_seconds=poll_seconds, + timeout_seconds=timeout_seconds, on_update=on_update) + + def recover(self, checkpoint): + """Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + + Re-resolves the route from the live registry and reads chain state once; + ``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, + ``done`` or ``failed``. + """ + return _lifecycle.recover(self, checkpoint) + + def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = None, + poll_seconds: float = 1.0, timeout_seconds: float = 120.0, proving: str = "delegate"): + """Finish an interrupted source submission (``progress.next == "resume"``). + + Rebroadcasts the identical proved Aleo transaction (a duplicate response is + success) or, on EVM, re-scans history and only then authorizes the single + missing deposit/dispatch. Never repeats a confirmed step. + """ + return _lifecycle.resume(self, progress, on_checkpoint=on_checkpoint, secret_nonce=secret_nonce, + poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, proving=proving) + + def complete(self, progress, *, secret_nonce: str, on_checkpoint=None, proving: str = "delegate"): + """Submit the private USDCx mint (``progress.next == "complete"``). + + Requires the same ``secret_nonce`` given to ``execute``; the SDK never + stored it. Submits exactly one ``private_mint`` and returns + ``DESTINATION_CONFIRMING`` progress to ``wait`` on. + """ + return _lifecycle.complete(self, progress, secret_nonce=secret_nonce, on_checkpoint=on_checkpoint, + proving=proving) + + def pending(self) -> list: + """``recover`` every checkpoint in the bound store — the in-flight transfers of this profile.""" + store = self.checkpoints + if store is None: + return [] + return [_lifecycle.recover(self, cp) for cp in store.list()] + # ── constructors ── @classmethod def from_env(cls, **overrides: Any) -> "Bridge": diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py new file mode 100644 index 00000000..432bae6b --- /dev/null +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -0,0 +1,111 @@ +"""The Bridge methods are one-liners; test that each forwards every argument to +lifecycle.* by calling the unbound methods with the FakeBridge as ``self``. + +Deviation from the task-9 brief (recorded in task-9-report.md): the brief's Step 3 also has +``__init__.py`` import ``bridge_tools``/``dispatch_tool`` from a new ``.agent`` module and add +``agent_guide()`` (reading a packaged ``AGENTS.md``), and has ``__main__.py`` print that guide. +The task-9 controller notes (ruling 5) explicitly forbid creating ``agent.py``/``AGENTS.md`` in +this task — those are Task 10/12's files — so this test file does not exercise them, and +``__main__.py`` is left untouched. +""" +import aleo_bridge +from aleo_bridge import lifecycle +from aleo_bridge.checkpoint import FileCheckpointStore, create_checkpoint +from aleo_bridge.client import Bridge +from aleo_bridge.types import Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge + +# The full pre-existing __all__ (plans 1-3, before this task's additive edit) — pinned so this +# task's edit can only ever ADD names, never drop one (controller ruling 1). +PRE_EXISTING_EXPORTS = [ + "__version__", "AmbiguousRouteError", "AttestationError", "BridgeError", "ChainMismatchError", + "CheckpointInvalidError", "ConfigurationError", "DeliveryUnknownError", "InsufficientBalanceError", + "InvalidAmountError", "InvalidRecipientError", "MissingExtraError", "NotResumableError", + "PollingTimeoutError", "RegistryVersionMismatchError", "RouteNotFoundError", "RouteUnavailableError", + "UnsupportedRouteError", + "Asset", "Chain", "DEFAULT_REGISTRY", "Locator", "Privacy", "Registry", "Route", "validate_registry", + "CALLER_BOUNDARIES", "TERMINAL", "AleoHyperlaneQuote", "AleoXReserveQuote", "Attestation", "BridgeStatus", + "BurnReceipt", "ChainStatus", "DepositReceipt", "DispatchReceipt", "EvmHyperlaneQuote", "EvmXReserveQuote", + "Fee", "GasQuote", "MintReceipt", "Plan", "PreparedTx", "PrivacyReceipt", "Progress", "Quote", "Receipt", + "SolanaHyperlaneQuote", "Status", "Step", "to_progress", + "AleoCall", "Bridge", "CircleClient", "DEFAULT_ENDPOINT", "EMPTY_MERKLE_PROOF_PAIR", "FreezeList", + "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", + "Checkpoint", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", + "EthModule", "Ethereum", "EvmCall", + "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", +] + +# This task's own additions (lifecycle module + the pure prepare() convenience import). +NEW_EXPORTS = ["lifecycle", "prepare"] + + +def _spy(monkeypatch, name): + seen = {} + + def fake(bridge, *args, **kwargs): + seen["args"], seen["kwargs"], seen["bridge"] = args, kwargs, bridge + return "result" + monkeypatch.setattr(lifecycle, name, fake) + return seen + + +def test_quote_forwards(monkeypatch): + seen = _spy(monkeypatch, "quote") + b = FakeBridge() + assert Bridge.quote(b, "ethereum/usdc", "aleo/usdcx", amount="2", recipient=ALEO_RECIPIENT, + mint_mode="private", secret_nonce="7scalar", sender=EVM_ADDRESS, protocol="xreserve") == "result" + assert seen["bridge"] is b and seen["args"] == () + assert seen["kwargs"] == dict(source="ethereum/usdc", destination="aleo/usdcx", amount="2", amount_atomic=None, + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, protocol="xreserve", + mint_mode="private", secret_nonce="7scalar") + + +def test_execute_wait_get_status_recover_resume_complete_forward(monkeypatch): + b = FakeBridge() + seen = _spy(monkeypatch, "execute") + cb = lambda cp: None + Bridge.execute(b, "PLAN", on_checkpoint=cb, proving="local", mode="signer", record="r", merkle_proof="m", + gas_payment_microcredits=5, secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) + assert seen["args"] == ("PLAN",) and seen["kwargs"] == dict( + on_checkpoint=cb, proving="local", mode="signer", record="r", merkle_proof="m", gas_payment_microcredits=5, + secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) + seen = _spy(monkeypatch, "wait") + Bridge.wait(b, "PROGRESS", until=[Status.DELIVERY_PENDING], poll_seconds=1, timeout_seconds=2, on_update=cb) + assert seen["args"] == ("PROGRESS",) and seen["kwargs"] == dict(until=[Status.DELIVERY_PENDING], poll_seconds=1, + timeout_seconds=2, on_update=cb) + seen = _spy(monkeypatch, "get_status") + Bridge.get_status(b, "PLAN", "RECEIPT") + assert seen["args"] == ("PLAN", "RECEIPT") + seen = _spy(monkeypatch, "recover") + Bridge.recover(b, {"version": 1}) + assert seen["args"] == ({"version": 1},) + seen = _spy(monkeypatch, "resume") + Bridge.resume(b, "PROGRESS", on_checkpoint=cb, secret_nonce="1scalar", poll_seconds=1.0, timeout_seconds=9.0) + assert seen["kwargs"] == dict(on_checkpoint=cb, secret_nonce="1scalar", poll_seconds=1.0, timeout_seconds=9.0, + proving="delegate") + seen = _spy(monkeypatch, "complete") + Bridge.complete(b, "PROGRESS", secret_nonce="7scalar", on_checkpoint=cb) + assert seen["kwargs"] == dict(secret_nonce="7scalar", on_checkpoint=cb, proving="delegate") + + +def test_pending_recovers_every_stored_checkpoint(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + plan = lifecycle.prepare(b.registry, source="aleo/eth", destination="ethereum/eth", + amount="0.000000000000000001", recipient=EVM_ADDRESS) + for tx in ("at1one", "at1two"): + store.save(create_checkpoint(plan, Receipt(id=tx, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id=tx, protocol_state={"routeId": plan.route_id}), b.registry)) + out = Bridge.pending(b) + assert [p.receipt.source_tx_id for p in out] == ["at1one", "at1two"] and all(p.next == "wait" for p in out) + assert Bridge.pending(FakeBridge(ethereum=False)) == [] + + +def test_public_exports_pin_pre_existing_set_and_add_lifecycle_names(): + for name in PRE_EXISTING_EXPORTS + NEW_EXPORTS: + assert hasattr(aleo_bridge, name), name + assert set(PRE_EXISTING_EXPORTS) <= set(aleo_bridge.__all__) + assert set(NEW_EXPORTS) <= set(aleo_bridge.__all__) + assert aleo_bridge.__version__ == "0.1.0" + assert aleo_bridge.lifecycle is lifecycle + assert aleo_bridge.prepare is lifecycle.prepare From 644f6de8d1309d942e8c93f0881b0e406d03e96b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:44:58 -0400 Subject: [PATCH 81/94] fix(bridge-sdk): resume requires the checkpointed xReserve hook; complete 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. --- bridge-sdk/python/aleo_bridge/lifecycle.py | 17 +++++++-- bridge-sdk/tests/test_resume_complete.py | 43 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index 5e9d37b5..cecfef0b 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -21,6 +21,7 @@ from . import _sealevel from ._calls import is_duplicate_submission +from .encoding import HOOK_DATA_BYTES from ._plan import build_plan from .checkpoint import Checkpoint, create_checkpoint from .errors import ( @@ -1079,7 +1080,9 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, """Finish the source leg an interruption left unsubmitted — never repeats an irreversible step. Requires ``progress.next == "resume"`` (status ``SOURCE_SUBMISSION_PENDING``); anything else is - a :class:`~aleo_bridge.errors.NotResumableError` pointing at ``wait``/``recover``. + a :class:`~aleo_bridge.errors.NotResumableError` pointing at ``wait``/``recover``. In particular + a ``SOURCE_APPROVAL_PENDING`` receipt is NOT resumable directly: call ``recover`` first, which + observes the approval and yields the ``SOURCE_SUBMISSION_PENDING`` progress this verb takes. Aleo source: rebroadcasts the checkpointed transaction byte-for-byte, after checking that the serialized payload's own id matches the saved one — a duplicate-transaction answer means the @@ -1149,6 +1152,15 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, _assert_sender(plan, bridge.ethereum.address, family="evm") is_xreserve = resolved.route.protocol == "xreserve" nonce = _mint_secret(plan, secret_nonce) if is_xreserve else None # before any RPC + saved_hook = state.get("hookData") + if is_xreserve and _hex_bytes(saved_hook, length=HOOK_DATA_BYTES) is None: + # Without the hook the approval committed to there is nothing to compare the re-quote + # against, so the guard below would silently pass and the deposit could be re-hooked to a + # different commitment. Refuse here, before any RPC, rather than resume half-blind. + raise NotResumableError( + "This transfer's checkpoint carries no xReserve hook data (a 65-byte 0x hex string); " + "recover() and re-quote instead of resuming — resume() will not re-derive the hook the " + "approval committed to") recovered = eth.recover_source(plan, create_checkpoint(plan, receipt, bridge.registry), required=True) if recovered.status is not Status.SOURCE_SUBMISSION_PENDING: @@ -1158,8 +1170,7 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, if is_xreserve: quoted = eth.quote_deposit_usdc(plan=plan, secret_nonce=nonce) - saved_hook = state.get("hookData") - if isinstance(saved_hook, str) and saved_hook.lower() != ("0x" + quoted.hook_data.hex()).lower(): + if saved_hook.lower() != ("0x" + quoted.hook_data.hex()).lower(): # always runs: validated above raise NotResumableError( "The re-quoted hook data does not match the hook this transfer's approval committed " "to: the secret nonce differs from the one used at execute(). Pass that same " diff --git a/bridge-sdk/tests/test_resume_complete.py b/bridge-sdk/tests/test_resume_complete.py index dd899156..a54670ad 100644 --- a/bridge-sdk/tests/test_resume_complete.py +++ b/bridge-sdk/tests/test_resume_complete.py @@ -194,6 +194,23 @@ def test_resume_refuses_when_the_re_quoted_hook_data_does_not_match_the_checkpoi assert "eth.deposit_usdc" not in [c[0] for c in b.calls] +@pytest.mark.parametrize("hook", [None, "not-hex", "0x", "0x" + "11" * 64, "0x" + "11" * 66, 65]) +def test_resume_refuses_an_xreserve_checkpoint_without_usable_hook_data(hook): + """Fix round 1 (R1): with no checkpointed hook there is nothing to compare the re-quote + against, so the equality guard below would pass vacuously and the deposit could be re-hooked to + a different commitment. Missing, malformed or wrong-width hook data is refused before any RPC — + including the history scan, so nothing is read on a transfer resume() will not finish.""" + b = FakeBridge() + plan, receipt = _xreserve_progress(b, mint_mode="private") + state = {k: v for k, v in receipt.protocol_state.items() if k != "hookData"} + if hook is not None: + state["hookData"] = hook + b.eth.recover_result = receipt + with pytest.raises(NotResumableError, match="hook data"): + resume(b, to_progress(plan, receipt.replace(protocol_state=state)), secret_nonce="7scalar") + assert b.calls == [] + + def test_resume_refuses_when_the_recovered_allowance_is_gone(): """veil guard 2: the approval this checkpoint recorded no longer covers the deposit — something else spent it. Re-approving here would be a second irreversible step resume never owns.""" @@ -288,6 +305,32 @@ def test_complete_proves_checkpoints_then_submits_one_private_mint(): assert "secretNonce" not in json.dumps(out.receipt.protocol_state) +def test_complete_never_writes_a_secret_to_the_bound_checkpoint_store(tmp_path): + """Fix round 1 (R2): the same claim as above, but proved against what actually reaches disk — + every byte the store wrote, not just the Checkpoint objects handed to the callback. The secret + nonce, Circle's attestation and the hook the deposit committed to must appear in none of it.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(environment="testnet", checkpoints=store) + plan, payload, message_hash, ready = _ready(b) + b.xreserve.expected_secret_nonce = "7scalar" + written = [] + real_save = store.save + store.save = lambda cp: (written.append(cp.to_json()), real_save(cp))[1] + + complete(b, to_progress(plan, ready), secret_nonce="7scalar") + + hook_hex = payload[-65:].hex() # the commitment the deposit was hooked to + secrets = ["7scalar", "secretNonce", SIG, SIG[2:], hook_hex, payload.hex(), "attestation"] + on_disk = [p.read_text(encoding="utf-8") for p in tmp_path.glob("*.json")] + assert written and on_disk # the store really was exercised + for text in written + on_disk: + for secret in secrets: + assert secret not in text, f"{secret!r} leaked into a checkpoint" + # ...and what IS kept is enough to recover the mint + assert [c.id for c in store.list()] == [message_hash] + assert store.list()[0].destination == {"transactionId": "at1fake1"} + + def test_complete_never_reaches_proving_with_a_nonce_that_opens_no_commitment(): b = FakeBridge(environment="testnet") plan, payload, message_hash, ready = _ready(b) From 7e646e4eac7b61a7dcc475f247ae52459a658d75 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 20:55:45 -0400 Subject: [PATCH 82/94] feat(bridge-sdk): agent tools over the lifecycle verbs with confirm-gated 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. --- bridge-sdk/python/aleo_bridge/__init__.py | 23 ++ bridge-sdk/python/aleo_bridge/agent.py | 412 ++++++++++++++++++++++ bridge-sdk/tests/fakes/fake_bridge.py | 12 +- bridge-sdk/tests/test_agent.py | 291 +++++++++++++++ bridge-sdk/tests/test_client_lifecycle.py | 21 +- 5 files changed, 751 insertions(+), 8 deletions(-) create mode 100644 bridge-sdk/python/aleo_bridge/agent.py create mode 100644 bridge-sdk/tests/test_agent.py diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index ca61c7e7..7d2aabb1 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from pathlib import Path + __version__ = "0.1.0" from .errors import ( # noqa: E402 @@ -30,6 +32,7 @@ from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 from .lifecycle import prepare # noqa: E402 +from .agent import bridge_tools, dispatch_tool # noqa: E402 from .privacy import PrivacyModule # noqa: E402 from .profile import DEFAULT_ENDPOINT, Profile # noqa: E402 from .sol import DEFAULT_SOLANA_RPC_URL, Solana, SolModule # noqa: E402 @@ -52,4 +55,24 @@ "EthModule", "Ethereum", "EvmCall", "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", "lifecycle", "prepare", + "agent_guide", "bridge_tools", "dispatch_tool", ] + +AGENTS_FILE = "AGENTS.md" + + +def agent_guide() -> str: + """The packaged agent guide: the prose a model reads alongside :func:`bridge_tools`. + + ``AGENTS.md`` is generated from the registry and the lifecycle docstrings by + ``codegen/gen_context.py`` and shipped inside the wheel. When it is not present (a source + checkout before that step has run), this returns a short pointer instead of failing — the tool + definitions themselves always carry their own descriptions. + """ + path = Path(__file__).with_name(AGENTS_FILE) + try: + return path.read_text(encoding="utf-8") + except OSError: + return (f"aleo_bridge {__version__}: no packaged {AGENTS_FILE} in this build. Generate it with " + "codegen/gen_context.py, or call aleo_bridge.bridge_tools() — every tool carries its " + "own description and JSON schema.") diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py new file mode 100644 index 00000000..3de300fb --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -0,0 +1,412 @@ +"""Framework-neutral agent tools over a :class:`~aleo_bridge.client.Bridge`. + +``bridge_tools()`` returns tool definitions in the Claude API ``tools=`` shape +(name / description / input_schema); ``dispatch_tool(bridge, name, args)`` +executes one and returns JSON-serializable data. Reads are open. Writes +require ``confirm: true`` — without it they return the quote (or recovered +progress / built call) plus ``how_to_confirm`` and move nothing. Agents never +carry ``Plan`` objects: ``bridge_execute`` takes the quote inputs and +re-quotes internally; recovery tools take the checkpoint dict returned by the +previous write. Amounts in and out are human units; ints stay ints. + +Three rules keep a model from doing damage with this surface: + +* **Secrets never leave the process.** ``_serialize`` drops the private-mint + secret, the Circle attestation body and the proved transaction bytes from any + receipt it renders, and no tool ever echoes its own arguments back. (The + xReserve *hook data* — a public commitment, not the secret that opens it — + stays on the quote and inside the checkpoint, because ``lifecycle.resume`` + refuses to resume a deposit whose checkpoint has lost it.) +* **A private mint never gets a default secret.** ``mint_mode="private"`` + without a ``secret_nonce`` is a structured error, never a quiet ``"0scalar"`` + that commits to a hook nobody can reproduce. +* **Failures come back as data.** Every :class:`~aleo_bridge.errors.BridgeError` + is rendered ``{"error", "error_type", "how_to_fix"}`` so the model can fix the + input or set up the missing connection instead of crashing its own loop; a + send whose outcome is ambiguous also carries ``{"next": "recover"}`` and the + last checkpoint. Programming errors (``ValueError``, ``TypeError``) still + propagate. +""" +from __future__ import annotations + +import dataclasses +import enum +from typing import Any, Callable + +from . import lifecycle +from .checkpoint import Checkpoint, create_checkpoint +from .errors import BridgeError, ConfigurationError +from .registry import DEFAULT_REGISTRY, Registry +from .types import EvmXReserveQuote, Receipt +from .units import format_decimal_amount + +_S = {"type": "string"} +_I = {"type": "integer"} +_B = {"type": "boolean"} +HOW_TO_CONFIRM = "re-call with confirm=true" + +EVM_HOW_TO_FIX = ("set EVM_PRIVATE_KEY + ETHEREUM_RPC_URL in the environment (or pass " + "ethereum=Ethereum(...) to Bridge(...)) and retry") +SOLANA_HOW_TO_FIX = ("set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) in the environment (or pass " + "solana=Solana(...) to Bridge(...)) and retry") +NONCE_HOW_TO_FIX = ("re-call with secret_nonce set to the value the user kept from execute — the SDK " + "never stored it, and no other value can open the commitment") +RECOVER_HOW_TO_FIX = ("the source step may already be on the wire: call bridge_get_progress with the " + "checkpoint (or bridge_pending) before doing anything else — never bridge_execute again") + +#: Receipt ``protocol_state`` keys that are dropped from every rendered receipt: the private-mint +#: secret and record plaintext (secret), the Circle attestation body (secret-adjacent and large), +#: the proved transaction bytes and the hook commitment (both live in the checkpoint, which is what +#: the recovery verbs actually consume). +_REDACTED_STATE_KEYS = frozenset({ + "payload", "attestation", "secretnonce", "record", "recordplaintext", "privatekey", + "hookdata", "preparedtransaction", "prepareddestinationtransaction", +}) + + +def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: + return {"type": "object", "properties": properties, "required": required} + + +# ── serialization ───────────────────────────────────────────────────────────── + +def _max_fee_entry(quote: EvmXReserveQuote, registry: Registry | None) -> dict[str, Any] | None: + """The xReserve max fee as a fee entry: ``EvmXReserveQuote.fees`` is empty, and an agent that + cannot see the fee cannot tell the user what the transfer costs.""" + plan = quote.plan + if plan is None: + return None + try: + asset = (registry or DEFAULT_REGISTRY).asset(plan.source_asset_id) + amount = format_decimal_amount(quote.max_fee_atomic, asset.decimals) + except BridgeError: + return None + return {"kind": "protocol", "chain_id": asset.chain_id, "asset_id": asset.id, "amount": amount, + "estimated": True, "label": "xReserve max fee"} + + +def _serialize(value: Any, registry: Registry | None = None) -> Any: + """Dataclasses → dicts, bytes → 0x hex, enums → values, tuples → lists; ints stay ints. + + Checkpoints render through ``to_dict()`` (the camelCase form the recovery tools take back), + receipts lose their secret / bulky ``protocol_state`` entries, and an ``EvmXReserveQuote`` + gains the synthetic max-fee entry its empty ``fees`` tuple would otherwise hide. + """ + if isinstance(value, Checkpoint): + return value.to_dict() + if isinstance(value, Receipt): + return {f.name: (_redacted_state(getattr(value, f.name)) if f.name == "protocol_state" + else _serialize(getattr(value, f.name), registry)) + for f in dataclasses.fields(value)} + if isinstance(value, EvmXReserveQuote): + out = {f.name: _serialize(getattr(value, f.name), registry) for f in dataclasses.fields(value)} + entry = _max_fee_entry(value, registry) + if entry is not None: + out["fees"] = list(out.get("fees") or []) + [entry] + return out + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return {f.name: _serialize(getattr(value, f.name), registry) for f in dataclasses.fields(value)} + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (bytes, bytearray)): + return "0x" + bytes(value).hex() + if isinstance(value, dict): + return {str(k): _serialize(v, registry) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_serialize(v, registry) for v in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _redacted_state(state: Any) -> Any: + if not isinstance(state, dict): + return _serialize(state) + return {str(k): _serialize(v) for k, v in state.items() + if str(k).replace("_", "").lower() not in _REDACTED_STATE_KEYS} + + +# ── errors ──────────────────────────────────────────────────────────────────── + +def _how_to_fix(exc: Exception) -> str | None: + message = str(exc).lower() + if "secret_nonce" in message or "secret nonce" in message: + return NONCE_HOW_TO_FIX + if "evm_private_key" in message or "ethereum_rpc_url" in message or "ethereum=ethereum" in message: + return EVM_HOW_TO_FIX + if "solana" in message and ("not configured" in message or "solana_private_key" in message): + return SOLANA_HOW_TO_FIX + return None + + +def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: + payload: dict[str, Any] = {"error": str(exc), "error_type": exc.__class__.__name__} + fix = _how_to_fix(exc) + if fix is not None: + payload["how_to_fix"] = fix + payload.update(extra) + return payload + + +def _connection_gap(bridge: Any, source: Any) -> dict[str, Any] | None: + """Probe the connection a transfer out of *source* would sign with (spec §10: a missing + connection is a configuration answer the model can act on, not an exception).""" + try: + asset = bridge.registry.asset(source) + family = bridge.registry.chain(asset.chain_id).family + except BridgeError: + return None # let the real lookup produce the real error + if family == "evm" and getattr(bridge, "ethereum", None) is None: + return _error_payload(ConfigurationError( + f"No Ethereum connection is configured, and {asset.id} transfers are signed on " + f"{asset.chain_id}."), how_to_fix=EVM_HOW_TO_FIX) + if family == "solana" and getattr(bridge, "solana", None) is None: + return _error_payload(ConfigurationError( + f"No Solana connection is configured, and {asset.id} transfers are signed on " + f"{asset.chain_id}."), how_to_fix=SOLANA_HOW_TO_FIX) + return None + + +def _missing_nonce(what: str) -> dict[str, Any]: + return _error_payload(ConfigurationError( + f"a secret_nonce is required for a private mint: {what} commits to (recipient, secret_nonce) " + "and bridge_complete needs the same value again — keep it, the SDK never stores it"), + how_to_fix=NONCE_HOW_TO_FIX) + + +# ── shared argument handling ────────────────────────────────────────────────── + +_QUOTE_PROPS = { + "source": {**_S, "description": "Source asset as 'chain/key', e.g. 'ethereum/usdc', 'aleo/eth', 'solana/sol'."}, + "destination": {**_S, "description": "Destination asset as 'chain/key', e.g. 'aleo/usdcx', 'ethereum/eth'."}, + "amount": {**_S, "description": "Positive decimal amount in source-asset display units (e.g. '2', '0.001')."}, + "recipient": {**_S, "description": "Destination-chain address that receives the funds."}, + "sender": {**_S, "description": "Optional source-chain address; must be the configured connection's address."}, + "protocol": {**_S, "enum": ["xreserve", "hyperlane"], "description": "Only needed when both protocols serve the pair."}, + "mint_mode": {**_S, "enum": ["public", "record", "private"], + "description": "xReserve into Aleo only. 'private' requires the user to complete the mint later with the same secret_nonce."}, + "secret_nonce": {**_S, "description": "Private-mint commitment secret, REQUIRED when mint_mode is 'private' " + "(there is no default). The user must keep it for bridge_complete; the SDK never stores it."}, +} +_QUOTE_REQUIRED = ["source", "destination", "amount", "recipient"] +_CONFIRM = {"confirm": {**_B, "description": "Set true to move funds. Without it the quote is returned and nothing is submitted."}} +_CHECKPOINT = {"checkpoint": {"type": "object", + "description": "The checkpoint dict returned by bridge_execute / bridge_pending / bridge_get_progress."}} + + +def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: + """Quote inputs. ``secret_nonce`` defaults to ``"0scalar"`` only for a non-private mint — + a private one must carry its own (checked by :func:`_private_nonce_gap` first).""" + mint_mode = args.get("mint_mode") or "public" + secret_nonce = args.get("secret_nonce") + return dict(source=args["source"], destination=args["destination"], amount=str(args["amount"]), + recipient=args["recipient"], sender=args.get("sender"), protocol=args.get("protocol"), + mint_mode=mint_mode, + secret_nonce=secret_nonce if mint_mode == "private" else (secret_nonce or "0scalar")) + + +def _private_nonce_gap(args: dict[str, Any]) -> dict[str, Any] | None: + if (args.get("mint_mode") or "public") == "private" and not args.get("secret_nonce"): + return _missing_nonce("the deposit") + return None + + +def _with_checkpoint(bridge: Any, progress: Any) -> dict[str, Any]: + checkpoint = create_checkpoint(progress.plan, progress.receipt, bridge.registry) + return {"progress": _serialize(progress, bridge.registry), "checkpoint": checkpoint.to_dict()} + + +def _confirmation(**payload: Any) -> dict[str, Any]: + return {"confirmation_required": True, **payload, "how_to_confirm": HOW_TO_CONFIRM} + + +# ── reads ───────────────────────────────────────────────────────────────────── + +def _h_status(b, a): + return _serialize(b.status(), b.registry) + + +def _h_list_assets(b, a): + return _serialize(b.registry.assets(chain=a.get("chain"), symbol=a.get("symbol"), + environment=a.get("environment", b.environment)), b.registry) + + +def _h_list_routes(b, a): + return _serialize(b.registry.routes(source=a.get("source"), destination=a.get("destination"), + protocol=a.get("protocol"), symbol=a.get("symbol"), + include_unavailable=bool(a.get("include_unavailable", False)), + environment=a.get("environment", b.environment)), b.registry) + + +def _h_quote(b, a): + gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source")) + if gap is not None: + return gap + return _serialize(lifecycle.quote(b, **_quote_kwargs(a)), b.registry) + + +def _h_get_progress(b, a): + return _serialize(lifecycle.recover(b, a["checkpoint"]), b.registry) + + +def _h_pending(b, a): + store = getattr(b, "checkpoints", None) + return [_serialize(lifecycle.recover(b, cp), b.registry) for cp in store.list()] if store is not None else [] + + +# ── writes (confirm-gated) ──────────────────────────────────────────────────── + +def _h_execute(b, a): + gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source")) + if gap is not None: + return gap + quote = lifecycle.quote(b, **_quote_kwargs(a)) + if not a.get("confirm"): + return _confirmation(quote=_serialize(quote, b.registry)) + seen: list[Checkpoint] = [] + try: + progress = lifecycle.execute( + b, quote.plan, on_checkpoint=seen.append, mode=a.get("mode"), proving=a.get("proving", "delegate"), + gas_payment_microcredits=a.get("gas_payment_microcredits"), secret_nonce=a.get("secret_nonce")) + except BridgeError as exc: + # A source call is single-use and a lost RPC response is ambiguous: never retry execute, + # hand back whatever checkpoint made it out so the model can recover from it. + payload = _error_payload(exc, next="recover") + payload["how_to_fix"] = RECOVER_HOW_TO_FIX + if seen: + payload["checkpoint"] = seen[-1].to_dict() + return payload + return _with_checkpoint(b, progress) + + +def _h_resume(b, a): + progress = lifecycle.recover(b, a["checkpoint"]) + if not a.get("confirm"): + return _confirmation(progress=_serialize(progress, b.registry)) + return _with_checkpoint(b, lifecycle.resume(b, progress, secret_nonce=a.get("secret_nonce"))) + + +def _has_prepared_destination(progress: Any) -> bool: + """A mint whose bytes are already proved rebroadcasts without the secret.""" + state = getattr(progress.receipt, "protocol_state", {}) or {} + return bool(state.get("preparedDestinationTransaction")) + + +def _h_complete(b, a): + progress = lifecycle.recover(b, a["checkpoint"]) # reads only + secret_nonce = a.get("secret_nonce") + if not secret_nonce and not _has_prepared_destination(progress): + return _missing_nonce("the deposit this mint finishes") + if not a.get("confirm"): + return _confirmation(progress=_serialize(progress, b.registry)) + return _with_checkpoint(b, lifecycle.complete(b, progress, secret_nonce=secret_nonce)) + + +def _privacy(b, a, direction: str): + kwargs = dict(asset=a["asset"], amount=a.get("amount"), amount_atomic=a.get("amount_atomic")) + call = b.shield(**kwargs) if direction == "shield" else b.unshield(**kwargs) + if not a.get("confirm"): + return _confirmation(call={"program": call.program_id, "function": call.function_name, + "inputs": list(call.inputs)}) + return _serialize(call.delegate(), b.registry) + + +def _h_shield(b, a): + return _privacy(b, a, "shield") + + +def _h_unshield(b, a): + return _privacy(b, a, "unshield") + + +# ── tool table ──────────────────────────────────────────────────────────────── + +_READ_TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ + ("bridge_status", + "Re-orient: environment, registry version, the configured Aleo/Ethereum/Solana addresses with balances of " + "every bridge asset (atomic units), and pending (in-flight) transfers. Run this FIRST in any session.", + _schema({}, []), _h_status), + ("bridge_list_assets", + "Assets that can be bridged, with chain, symbol, decimals and on-chain locator. Filter by chain id " + "(aleo, ethereum, solana, aleo-testnet, sepolia) or symbol.", + _schema({"chain": _S, "symbol": _S, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_assets), + ("bridge_list_routes", + "Supported directions and their protocol (xreserve = USDC<->USDCx via Circle; hyperlane = ETH/WBTC/USDT/SOL). " + "Active routes move funds; metadata-required ones are listed but refused by quote/execute.", + _schema({"source": _S, "destination": _S, "protocol": {**_S, "enum": ["xreserve", "hyperlane"]}, "symbol": _S, + "include_unavailable": _B, "environment": {**_S, "enum": ["mainnet", "testnet"]}}, []), _h_list_routes), + ("bridge_quote", + "Validate and price a transfer: route, fees (human units), amount_out, approval needs. Reads chain state, " + "never signs. ALWAYS quote before bridge_execute and show the user fees and amount_out. A private mint " + "(mint_mode='private') must carry the user's own secret_nonce — there is no default.", + _schema(_QUOTE_PROPS, _QUOTE_REQUIRED), _h_quote), + ("bridge_get_progress", + "Recover a transfer's state from a checkpoint (reads only). progress.next tells what to do: wait (call again " + "later), resume (bridge_resume), complete (bridge_complete), done, failed.", + _schema(_CHECKPOINT, ["checkpoint"]), _h_get_progress), + ("bridge_pending", + "Every in-flight transfer in this profile's checkpoint store, recovered to current progress.", + _schema({}, []), _h_pending), +] + +_WRITE_TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ + ("bridge_execute", + "Start a transfer: re-quotes the same inputs, then commits funds on the source chain. Requires confirm=true; " + "without it returns the quote and moves nothing. The source step is IRREVERSIBLE once broadcast — afterwards " + "use bridge_get_progress with the returned checkpoint, never bridge_execute again. For mint_mode=private the " + "user must supply secret_nonce here (no default) and keep it for bridge_complete.", + _schema({**_QUOTE_PROPS, "mode": {**_S, "description": "Aleo-origin only: caller|signer (Hyperlane) or " + "private|public|public-as-signer (xReserve burn)."}, + "gas_payment_microcredits": _I, "proving": {**_S, "enum": ["delegate", "local"]}, **_CONFIRM}, + _QUOTE_REQUIRED), _h_execute), + ("bridge_resume", + "Finish an interrupted source submission (progress.next == 'resume'): rebroadcasts the identical proved Aleo " + "transaction or authorizes the single missing EVM step after re-scanning history. Requires confirm=true. " + "An interrupted EVM xReserve deposit needs the same secret_nonce used at execute.", + _schema({**_CHECKPOINT, "secret_nonce": {**_S, "description": "The value used at bridge_execute; required to " + "resume a private-mint xReserve deposit."}, + **_CONFIRM}, ["checkpoint"]), _h_resume), + ("bridge_complete", + "Submit the private USDCx mint (progress.next == 'complete') with the secret_nonce used at execute. " + "Requires confirm=true. Submits exactly one Aleo transaction.", + _schema({**_CHECKPOINT, + "secret_nonce": {**_S, "description": "The private-mint secret used at bridge_execute — required " + "(there is no default); only an already-proved mint can be " + "rebroadcast without it."}, + **_CONFIRM}, ["checkpoint"]), _h_complete), + ("bridge_shield", + "Move a public Aleo balance of a bridged asset (aleo/eth, aleo/wbtc, aleo/usdt, aleo/sol, aleo/usdcx) into a " + "private record. Requires confirm=true; without it returns the program call for review.", + _schema({"asset": _S, "amount": _S, "amount_atomic": _I, **_CONFIRM}, ["asset"]), _h_shield), + ("bridge_unshield", + "Move a private record of a bridged asset back to the public balance (needed before an Aleo-origin Hyperlane " + "transfer). Requires confirm=true.", + _schema({"asset": _S, "amount": _S, "amount_atomic": _I, **_CONFIRM}, ["asset"]), _h_unshield), +] + +_HANDLERS: dict[str, Callable[[Any, dict[str, Any]], Any]] = { + name: handler for name, _, _, handler in _READ_TOOLS + _WRITE_TOOLS} + + +def bridge_tools(include_writes: bool = True) -> list[dict[str, Any]]: + """Tool definitions (Claude API ``tools=`` shape); ``include_writes=False`` keeps only reads.""" + tools = _READ_TOOLS + (_WRITE_TOOLS if include_writes else []) + return [{"name": name, "description": desc, "input_schema": schema} for name, desc, schema, _ in tools] + + +def dispatch_tool(bridge: Any, name: str, args: dict[str, Any] | None = None) -> Any: + """Execute one tool against *bridge*; returns JSON-serializable data. + + A :class:`~aleo_bridge.errors.BridgeError` becomes ``{"error", "error_type", ...}`` — the model + gets to fix the input rather than lose its loop. An unknown tool name is a ``ValueError``. + """ + handler = _HANDLERS.get(name) + if handler is None: + raise ValueError(f"Unknown bridge tool: {name!r}") + try: + return handler(bridge, dict(args or {})) + except BridgeError as exc: + return _error_payload(exc) + + +__all__ = ["bridge_tools", "dispatch_tool", "HOW_TO_CONFIRM"] diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 2fe3aa48..9eae11a5 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -83,9 +83,10 @@ class FakeEvmCall: """ def __init__(self, fake: "FakeBridge", intermediates: list[Receipt], final: Any, *, - plan: Any = None, store: Any = None) -> None: + plan: Any = None, store: Any = None, send_error: Exception | None = None) -> None: self.fake, self.intermediates, self.final = fake, intermediates, final self.plan, self.store = plan, store + self.send_error = send_error # raised after the intermediates: an ambiguous broadcast def build(self) -> list[dict]: return [{"to": "0xrouter", "data": "0x", "value": 0}] @@ -101,6 +102,10 @@ def send(self, *, wait=True, timeout_seconds=120.0, poll_seconds=1.0, on_checkpo self.fake.events.append(("evm_send", timeout_seconds, poll_seconds)) for receipt in self.intermediates: self._emit(receipt, on_checkpoint) + if self.send_error is not None: + # Mirrors the real call: the transaction is armed (approvals are already checkpointed) + # but its own outcome is unknown — a single-use call that must never be retried. + raise self.send_error self._emit(self.final.receipt, on_checkpoint) return self.final @@ -235,6 +240,7 @@ def __init__(self, fake: "FakeBridge", address: str) -> None: self.source_status_result: Receipt | None = None self.recover_result: Receipt | None = None self.intermediates: list[Receipt] = [] + self.send_error: Exception | None = None # raised by send() after the intermediates def quote_transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_atomic=None, route=None, sender=None, plan=None): @@ -284,7 +290,7 @@ def transfer_remote(self, asset=None, recipient=None, *, amount=None, amount_ato "amountAtomic": str(amount_atomic or 0)}) return FakeEvmCall(self.fake, self.intermediates, DispatchReceipt(tx, route_id, None, amount_atomic or 0, receipt), - plan=plan, store=self.fake.checkpoints) + plan=plan, store=self.fake.checkpoints, send_error=self.send_error) def quote_deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_mode=None, secret_nonce="0scalar", sender=None, route=None, plan=None): @@ -334,7 +340,7 @@ def deposit_usdc(self, recipient=None, *, amount=None, amount_atomic=None, mint_ "bridgeProgram": "usdcx_bridge_v2.aleo"}) return FakeEvmCall(self.fake, self.intermediates, DepositReceipt(tx, route_id, message_hash, "0x" + "dd" * 32, receipt), - plan=plan, store=self.fake.checkpoints) + plan=plan, store=self.fake.checkpoints, send_error=self.send_error) def balance(self, asset) -> int: self.fake.calls.append(("eth.balance", asset)) diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py new file mode 100644 index 00000000..04275cca --- /dev/null +++ b/bridge-sdk/tests/test_agent.py @@ -0,0 +1,291 @@ +"""Agent tools: the Claude-shape surface, the confirm gate, and what must never leave the process. + +The brief's own cases (surface/schemas, _serialize, reads, the four write gates) are kept as +written; the controller's rulings add: no "0scalar" fallback for a private mint, no secret in any +tool result, the synthetic xReserve max-fee entry, structured errors for an unconfigured chain, +and `next: "recover"` guidance when a send's outcome is ambiguous. +""" +import json + +import pytest + +from aleo_bridge.agent import _serialize, bridge_tools, dispatch_tool +from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.errors import BridgeError +from aleo_bridge.lifecycle import prepare +from aleo_bridge.types import Fee, Receipt, Status +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge + +READS = {"bridge_status", "bridge_list_assets", "bridge_list_routes", "bridge_quote", "bridge_get_progress", + "bridge_pending"} +WRITES = {"bridge_execute", "bridge_resume", "bridge_complete", "bridge_shield", "bridge_unshield"} +QUOTE_ARGS = {"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", "recipient": ALEO_RECIPIENT} +NONCE = "7scalar" + + +def _aleo_out_checkpoint(b): + """A checkpoint for an Aleo-origin Hyperlane transfer that was proved but never broadcast.""" + plan = prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", + recipient=EVM_ADDRESS) + serialized = json.dumps({"type": "execute", "id": "at1prepared", "fee": {}}) + cp = {"version": 1, + "intent": {"source": {"chain": "aleo", "asset": "eth"}, + "destination": {"chain": "ethereum", "asset": "eth"}, + "bridgeProtocol": "hyperlane", "amount": plan.amount, "recipient": EVM_ADDRESS}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"preparedTransaction": {"transactionId": "at1prepared", + "serializedTransaction": serialized}}} + return cp, serialized + + +def _inbound_private_checkpoint(b): + """A testnet xReserve deposit attested and waiting for its private mint.""" + from aleo_bridge.types import Attestation + from tests.test_get_status import SIG, _inbound_private + + plan, payload, message_hash, receipt = _inbound_private(b) + b.xreserve.attestations[message_hash] = Attestation(payload, bytes.fromhex(message_hash[2:]), + bytes.fromhex(SIG[2:]), "complete") + b.eth.recover_result = receipt + return {"version": 1, + "intent": {"source": {"chain": "sepolia", "asset": "usdc"}, + "destination": {"chain": "aleo-testnet", "asset": "usdcx"}, + "bridgeProtocol": "xreserve", "amount": "2", "recipient": ALEO_RECIPIENT, + "mintMode": "private"}, + "route": {"id": plan.route_id, "registryVersion": plan.registry_version}, + "source": {"transactionId": "0x" + "22" * 32}} + + +# ── surface ─────────────────────────────────────────────────────────────────── + +def test_tool_surface_and_schemas(): + tools = bridge_tools() + assert {t["name"] for t in tools} == READS | WRITES + assert {t["name"] for t in bridge_tools(include_writes=False)} == READS + for t in tools: + assert t["description"] and t["input_schema"]["type"] == "object" + json.dumps(t) + if t["name"] in WRITES: + assert t["input_schema"]["properties"]["confirm"]["type"] == "boolean" + assert "confirm" in t["description"].lower() + execute = next(t for t in tools if t["name"] == "bridge_execute") + assert set(execute["input_schema"]["required"]) == {"source", "destination", "amount", "recipient"} + assert "plan" not in execute["input_schema"]["properties"] # agents never carry plans + quote = next(t for t in tools if t["name"] == "bridge_quote") + assert quote["input_schema"]["properties"]["mint_mode"]["enum"] == ["public", "record", "private"] + assert "private key" not in json.dumps(tools).lower() + + +def test_private_mint_descriptions_state_the_nonce_requirement(): + tools = {t["name"]: t for t in bridge_tools()} + for name in ("bridge_quote", "bridge_execute", "bridge_complete"): + assert "secret_nonce" in tools[name]["input_schema"]["properties"] + for name in ("bridge_execute", "bridge_complete"): + description = tools[name]["input_schema"]["properties"]["secret_nonce"]["description"].lower() + assert "required" in description and "0scalar" not in description + + +# ── _serialize ──────────────────────────────────────────────────────────────── + +def test_serialize(): + fee = Fee(kind="protocol", chain_id="aleo", asset_id="aleo/aleo", amount="8.174147", estimated=True) + out = _serialize({"fee": fee, "raw": b"\x01\xff", "status": Status.COMPLETED, "n": 10**20, "t": (1, 2), "none": None}) + assert out == {"fee": {"kind": "protocol", "chain_id": "aleo", "asset_id": "aleo/aleo", "amount": "8.174147", + "estimated": True}, "raw": "0x01ff", "status": "COMPLETED", "n": 10**20, "t": [1, 2], "none": None} + json.dumps(out) + + +def test_serialize_adds_the_xreserve_max_fee_entry(): + b = FakeBridge() + quote = dispatch_tool(b, "bridge_quote", QUOTE_ARGS) + assert quote["fees"] == [{"kind": "protocol", "chain_id": "ethereum", "asset_id": "ethereum/usdc", + "amount": "0.1", "estimated": True, "label": "xReserve max fee"}] + # and the same entry appears when _serialize is handed the quote object on its own + from aleo_bridge import lifecycle + raw = lifecycle.quote(b, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT) + assert _serialize(raw)["fees"][-1]["label"] == "xReserve max fee" + + +def test_serialize_drops_secret_and_bulky_receipt_state(): + receipt = Receipt(id="0x01", protocol="xreserve", status=Status.ATTESTATION_PENDING, + protocol_state={"routeId": "r", "payload": "0x" + "ee" * 305, "attestation": "0x11", + "secretNonce": NONCE, "preparedTransaction": "{...}", "mintMode": "private"}) + out = _serialize(receipt) + assert out["protocol_state"] == {"routeId": "r", "mintMode": "private"} + assert NONCE not in json.dumps(out) + + +# ── reads ───────────────────────────────────────────────────────────────────── + +def test_read_tools(): + b = FakeBridge() + b.public_balances["aleo/usdcx"] = 5_000_000 + status = dispatch_tool(b, "bridge_status", {}) + assert status["environment"] == "mainnet" and status["chains"][0]["balances"] == {"aleo/usdcx": 5_000_000} + assets = dispatch_tool(b, "bridge_list_assets", {"chain": "aleo"}) + assert {a["symbol"] for a in assets} >= {"USDCx", "ETH", "WBTC", "USDT", "SOL"} and all(a["chain_id"] == "aleo" for a in assets) + routes = dispatch_tool(b, "bridge_list_routes", {"protocol": "xreserve"}) + assert routes and all(r["protocol"] == "xreserve" and r["availability"] == "active" for r in routes) + all_routes = dispatch_tool(b, "bridge_list_routes", {"include_unavailable": True}) + assert any(r["availability"] == "metadata-required" for r in all_routes) + q = dispatch_tool(b, "bridge_quote", {**QUOTE_ARGS, "mint_mode": "private", "secret_nonce": NONCE}) + assert q["kind"] == "evm-xreserve" and q["plan"]["route_id"] == "xreserve:ethereum/usdc->aleo/usdcx" + assert q["plan"]["amount"] == "2" and q["hook_data"].startswith("0x") + json.dumps(q) + assert NONCE not in json.dumps(q) + assert b.events == [] # nothing signed + + +# ── writes ──────────────────────────────────────────────────────────────────── + +def test_execute_requires_confirm_and_requotes_internally(): + b = FakeBridge() + out = dispatch_tool(b, "bridge_execute", QUOTE_ARGS) + assert out["confirmation_required"] is True and out["how_to_confirm"] == "re-call with confirm=true" + assert out["quote"]["kind"] == "evm-xreserve" and out["quote"]["plan"]["amount"] == "2" + assert [c[0] for c in b.calls] == ["eth.quote_deposit_usdc"] and b.events == [] + b.calls.clear() + out = dispatch_tool(b, "bridge_execute", {**QUOTE_ARGS, "confirm": True}) + assert [c[0] for c in b.calls] == ["eth.quote_deposit_usdc", "eth.deposit_usdc"] + assert out["progress"]["next"] == "wait" and out["progress"]["receipt"]["status"] == "ATTESTATION_PENDING" + assert out["checkpoint"]["version"] == 1 and out["checkpoint"]["route"]["id"] == "xreserve:ethereum/usdc->aleo/usdcx" + assert "secretNonce" not in json.dumps(out) and "payload" not in json.dumps(out["checkpoint"]) + json.dumps(out) + + +def test_get_progress_and_pending(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + out = dispatch_tool(b, "bridge_execute", {"source": "aleo/eth", "destination": "ethereum/eth", + "amount": "0.000000000000000001", "recipient": EVM_ADDRESS, + "gas_payment_microcredits": 1, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" + progress = dispatch_tool(b, "bridge_get_progress", {"checkpoint": out["checkpoint"]}) + assert progress["next"] == "wait" and progress["receipt"]["source_tx_id"] == "at1fake1" + assert b.submitted.count(b.submitted[0]) == 1 # recover never rebroadcasts + pending = dispatch_tool(b, "bridge_pending", {}) + assert [p["receipt"]["id"] for p in pending] == ["at1fake1"] + + +def test_resume_and_complete_gates(): + b = FakeBridge(ethereum=False) + cp, serialized = _aleo_out_checkpoint(b) + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp}) + assert out["confirmation_required"] is True and out["progress"]["next"] == "resume" and b.aleo.submitted == [] + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp, "confirm": True}) + # a resumed Aleo leg rebroadcasts the checkpointed bytes through the network, never re-proving + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" and b.aleo.submitted == [serialized] + + b2 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b2) + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE}) + assert out["confirmation_required"] is True and out["progress"]["next"] == "complete" and b2.events == [] + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "DESTINATION_CONFIRMING" + assert NONCE not in json.dumps(out) + + +def test_shield_unshield_gates(): + b = FakeBridge() + out = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount": "0.000000000000000001"}) + assert out["confirmation_required"] is True and out["call"]["function"] == "shield" and b.events == [] + out = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount": "0.000000000000000001", "confirm": True}) + assert out["direction"] == "shield" and b.events[-1][0] == "delegate" + out = dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, "confirm": True}) + assert out["direction"] == "unshield" and out["amount_atomic"] == 1 + + +def test_unknown_tool(): + with pytest.raises(ValueError, match="Unknown bridge tool"): + dispatch_tool(FakeBridge(), "nope", {}) + + +# ── controller rulings ──────────────────────────────────────────────────────── + +def test_private_mint_never_defaults_the_secret_nonce(): + b = FakeBridge() + private = {**QUOTE_ARGS, "mint_mode": "private"} + for tool in ("bridge_quote", "bridge_execute"): + out = dispatch_tool(b, tool, dict(private)) + assert "secret_nonce" in out["error"] and out["how_to_fix"] + assert "confirmation_required" not in out and "quote" not in out + out = dispatch_tool(b, "bridge_execute", {**private, "confirm": True}) + assert "secret_nonce" in out["error"] + assert b.calls == [] and b.events == [] # nothing quoted, nothing moved + # a public mint still defaults quietly + assert dispatch_tool(b, "bridge_quote", QUOTE_ARGS)["kind"] == "evm-xreserve" + + b2 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b2) + out = dispatch_tool(b2, "bridge_complete", {"checkpoint": cp2, "confirm": True}) + assert "secret_nonce" in out["error"] and out["how_to_fix"] and b2.events == [] + + +def test_no_write_tool_ever_echoes_the_secret_nonce(): + """Every write tool, with and without confirm: the nonce is not in the JSON result.""" + results = [] + + b = FakeBridge() + private = {**QUOTE_ARGS, "mint_mode": "private", "secret_nonce": NONCE} + results.append(dispatch_tool(b, "bridge_execute", dict(private))) + results.append(dispatch_tool(b, "bridge_execute", {**private, "confirm": True})) + results.append(dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE})) + results.append(dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE, "confirm": True})) + results.append(dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE})) + results.append(dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1, + "secret_nonce": NONCE, "confirm": True})) + + b2 = FakeBridge(ethereum=False) + cp, _ = _aleo_out_checkpoint(b2) + results.append(dispatch_tool(b2, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE})) + results.append(dispatch_tool(b2, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True})) + + b3 = FakeBridge(environment="testnet") + cp2 = _inbound_private_checkpoint(b3) + results.append(dispatch_tool(b3, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE})) + results.append(dispatch_tool(b3, "bridge_complete", {"checkpoint": cp2, "secret_nonce": NONCE, "confirm": True})) + + assert len(results) == 10 + for result in results: + blob = json.dumps(result) + assert NONCE not in blob and "secretNonce" not in blob and "secret_nonce" not in blob + + +def test_unconfigured_chain_returns_a_structured_error(): + b = FakeBridge(ethereum=False) # no EVM connection + for tool, args in (("bridge_quote", QUOTE_ARGS), ("bridge_execute", {**QUOTE_ARGS, "confirm": True})): + out = dispatch_tool(b, tool, dict(args)) + assert out["error"] and out["error_type"] in {"ConfigurationError", "BridgeError"} + assert "EVM_PRIVATE_KEY" in out["how_to_fix"] and "ETHEREUM_RPC_URL" in out["how_to_fix"] + assert "next" not in out + assert b.calls == [] and b.events == [] + out = dispatch_tool(FakeBridge(solana=False), "bridge_quote", + {"source": "solana/sol", "destination": "aleo/sol", "amount": "0.1", + "recipient": ALEO_RECIPIENT}) + assert "SOLANA_PRIVATE_KEY" in out["how_to_fix"] + + +def test_bridge_error_from_a_read_is_structured_too(): + out = dispatch_tool(FakeBridge(), "bridge_quote", {**QUOTE_ARGS, "amount": "0"}) + assert out["error"] and out["error_type"] == "InvalidAmountError" + + +def test_ambiguous_send_surfaces_recover_guidance_and_the_checkpoint(): + b = FakeBridge() + route_id = "xreserve:ethereum/usdc->aleo/usdcx" + approval = Receipt(id="0x" + "11" * 32, protocol="xreserve", status=Status.SOURCE_APPROVAL_PENDING, + protocol_state={"routeId": route_id, "approvalTxIds": ["0x" + "11" * 32], + "sourceSender": EVM_ADDRESS}) + b.eth.intermediates = [approval] + b.eth.send_error = BridgeError("Ethereum deposit 0x" + "bb" * 32 + " may already be broadcast: " + "the RPC response was lost") + out = dispatch_tool(b, "bridge_execute", {**QUOTE_ARGS, "confirm": True}) + assert out["error_type"] == "BridgeError" and "may already be broadcast" in out["error"] + assert out["next"] == "recover" and "bridge_get_progress" in out["how_to_fix"] + assert out["checkpoint"]["receiptId"] == "0x" + "11" * 32 + assert out["checkpoint"]["route"]["id"] == route_id + json.dumps(out) diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py index 432bae6b..9fe40ace 100644 --- a/bridge-sdk/tests/test_client_lifecycle.py +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -5,11 +5,14 @@ ``__init__.py`` import ``bridge_tools``/``dispatch_tool`` from a new ``.agent`` module and add ``agent_guide()`` (reading a packaged ``AGENTS.md``), and has ``__main__.py`` print that guide. The task-9 controller notes (ruling 5) explicitly forbid creating ``agent.py``/``AGENTS.md`` in -this task — those are Task 10/12's files — so this test file does not exercise them, and -``__main__.py`` is left untouched. +that task — those are Task 10/12's files. Task 10 landed ``agent.py`` and those three exports, +so the export pin below now covers them; ``AGENTS.md`` is still Task 12's, and ``__main__.py`` +is left untouched. """ +from pathlib import Path + import aleo_bridge -from aleo_bridge import lifecycle +from aleo_bridge import agent, lifecycle from aleo_bridge.checkpoint import FileCheckpointStore, create_checkpoint from aleo_bridge.client import Bridge from aleo_bridge.types import Receipt, Status @@ -35,8 +38,9 @@ "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", ] -# This task's own additions (lifecycle module + the pure prepare() convenience import). -NEW_EXPORTS = ["lifecycle", "prepare"] +# This task's own additions (lifecycle module + the pure prepare() convenience import), then the +# agent surface Task 9 deferred to Task 10 (bridge_tools/dispatch_tool + the packaged guide). +NEW_EXPORTS = ["lifecycle", "prepare", "agent_guide", "bridge_tools", "dispatch_tool"] def _spy(monkeypatch, name): @@ -109,3 +113,10 @@ def test_public_exports_pin_pre_existing_set_and_add_lifecycle_names(): assert aleo_bridge.__version__ == "0.1.0" assert aleo_bridge.lifecycle is lifecycle assert aleo_bridge.prepare is lifecycle.prepare + assert aleo_bridge.bridge_tools is agent.bridge_tools + assert aleo_bridge.dispatch_tool is agent.dispatch_tool + # AGENTS.md is Task 12's generated file: until it ships, the guide is a pointer, never an error + guide = aleo_bridge.agent_guide() + assert isinstance(guide, str) and guide + if not (Path(aleo_bridge.__file__).with_name("AGENTS.md")).exists(): + assert "codegen/gen_context.py" in guide From d841db42a28933ea871fc6abae6ea797508a446b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 17 Sep 2026 21:07:43 -0400 Subject: [PATCH 83/94] fix(bridge-sdk): pending() is offline; Bridge.wait forwards the retry controls --- bridge-sdk/python/aleo_bridge/client.py | 26 ++- bridge-sdk/python/aleo_bridge/lifecycle.py | 203 ++++++++++++++++----- bridge-sdk/tests/test_client_lifecycle.py | 75 +++++++- 3 files changed, 242 insertions(+), 62 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 0d417f51..479b041d 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -16,7 +16,7 @@ from . import lifecycle as _lifecycle from ._calls import AleoCall -from .errors import ConfigurationError +from .errors import BridgeError, ConfigurationError from .eth import Ethereum, EthModule from .freezelist import FreezeList from .hyperlane import HyperlaneModule @@ -338,16 +338,19 @@ def get_status(self, plan, receipt): return _lifecycle.get_status(self, plan, receipt) def wait(self, progress, *, until=None, poll_seconds: float = 15.0, timeout_seconds: float = 1200.0, - on_update=None): + on_update=None, on_error=None, max_consecutive_errors: int = 5): """Poll until the transfer finishes or needs you: stops at ``progress.next`` in resume / complete / done / failed, or at any status in ``until``. A ``PollingTimeoutError`` is NOT a failure — the transfer is still in flight; call ``wait`` again or ``recover`` later. ``on_update`` receives - each changed ``Progress``. + each changed ``Progress``. A transient error (flaky RPC/HTTP transport) + is retried up to ``max_consecutive_errors`` times, calling ``on_error`` + on each tolerated retry; a non-transient error propagates immediately. """ return _lifecycle.wait(self, progress, until=until, poll_seconds=poll_seconds, - timeout_seconds=timeout_seconds, on_update=on_update) + timeout_seconds=timeout_seconds, on_update=on_update, on_error=on_error, + max_consecutive_errors=max_consecutive_errors) def recover(self, checkpoint): """Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. @@ -380,11 +383,22 @@ def complete(self, progress, *, secret_nonce: str, on_checkpoint=None, proving: proving=proving) def pending(self) -> list: - """``recover`` every checkpoint in the bound store — the in-flight transfers of this profile.""" + """The in-flight transfers of this profile — every checkpoint in the bound store, + reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one + unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` + with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` + on any entry to refresh it against live chain state. + """ store = self.checkpoints if store is None: return [] - return [_lifecycle.recover(self, cp) for cp in store.list()] + out = [] + for cp in store.list(): + try: + out.append(_lifecycle.progress_from_checkpoint(self.registry, cp)) + except BridgeError: + continue # no Plan could be rebuilt at all (bad format/version/route) — nothing to report + return out # ── constructors ── @classmethod diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index cecfef0b..d2a567b9 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -865,8 +865,8 @@ def wait(bridge, progress: Progress, *, until=None, poll_seconds: float = 15.0, __all__ = ["MINT_MODES", "ResolvedRoute", "aleo_transaction_status", "complete", "execute", "get_status", - "is_duplicate_broadcast_error", "prepare", "quote", "recover", "resolve_route", "resume", - "submit_serialized", "wait"] + "is_duplicate_broadcast_error", "prepare", "progress_from_checkpoint", "quote", "recover", + "resolve_route", "resume", "submit_serialized", "wait"] # ── recover ─────────────────────────────────────────────────────────────────── @@ -927,43 +927,37 @@ def _finish(bridge, plan: Plan, receipt: Receipt, checkpoint_id: str) -> Progres return to_progress(plan, receipt) -def recover(bridge, checkpoint) -> Progress: - """Rebuild a transfer's ``Progress`` from a saved checkpoint — reads only, never signs. - - Accepts a ``Checkpoint``, its dict, or its JSON. Re-runs ``prepare`` on the - saved intent against the LIVE registry, then checks the route id and registry - version (``CheckpointInvalidError`` / ``RegistryVersionMismatchError``). - Aleo source: a proved-but-unbroadcast transaction yields ``next == "resume"`` - with no network read; a submitted one gets exactly one ``get_status`` from - ``SOURCE_CONFIRMING``. Solana: validates the blockhash pair and reads the - signature status. EVM: delegates to ``bridge.eth.recover_source`` (log - scan); inbound xReserve additionally restores a submitted or prepared - private mint. The result's ``next`` tells the caller what to do. +def _verification_from_delivery(dv: dict[str, Any]) -> dict[str, str]: + """Validate and translate a checkpoint's ``deliveryVerification`` block (Aleo-origin Hyperlane + only); ``{}`` when absent. Raises :class:`CheckpointInvalidError` on a malformed block (missing + key or non-digit value) rather than ``KeyError`` — the block came from a stored file, not a + live read, so it is untrusted input.""" + if not dv: + return {} + before, expected = dv.get("balanceBeforeAtomic"), dv.get("expectedIncreaseAtomic") + if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): + raise CheckpointInvalidError("Bridge checkpoint contains invalid destination balance verification state") + return {"destinationBalanceBeforeAtomic": before, "expectedDestinationIncreaseAtomic": expected} + + +def _reconstruct_source_receipt(plan: Plan, resolved: ResolvedRoute, cp: Checkpoint, + verification: dict[str, str]) -> Receipt: + """Build the pre-refresh source ``Receipt`` purely from a checkpoint's stored fields — no + network, no signing. Shared by ``recover`` (which then reads live chain state to refine + non-``SOURCE_SUBMISSION_PENDING`` results) and the fully offline ``progress_from_checkpoint`` + (which stops here), so the two can never drift apart on what a checkpoint alone can tell you. + + Mirrors ``create_checkpoint``'s own allowlist: a ``preparedTransaction`` with no + ``transactionId`` is an unbroadcast Aleo leg (``SOURCE_SUBMISSION_PENDING`` — next: resume); + a ``transactionId`` alone (any chain family) is a submitted, still-confirming leg + (``SOURCE_CONFIRMING``); an EVM leg with only approvals and no ``transactionId`` yet is + waiting on its deposit/dispatch (``SOURCE_SUBMISSION_PENDING`` too — the same "call resume()" + signal; ``resume()`` re-verifies against live chain history before repeating anything, so an + offline guess here is never unsafe); anything else has nothing to build a receipt from. """ - cp = _coerce_checkpoint(checkpoint) - if cp.version != 1 or not cp.intent or not cp.route: - raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") - plan = _plan_from_intent(bridge.registry, cp.intent) - if cp.route.get("registryVersion") != plan.registry_version: - raise RegistryVersionMismatchError( - f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " - f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") - if cp.route.get("id") != plan.route_id: - raise CheckpointInvalidError( - f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") - resolved = resolve_route(bridge.registry, plan) - src, dst = resolved.source_chain, resolved.destination_chain + src = resolved.source_chain source = cp.source or {} - dv = cp.delivery_verification or {} - if dv: - before, expected = dv.get("balanceBeforeAtomic"), dv.get("expectedIncreaseAtomic") - if not (isinstance(before, str) and before.isdigit() and isinstance(expected, str) and expected.isdigit()): - raise CheckpointInvalidError( - "Bridge checkpoint contains invalid destination balance verification state") - verification = {"destinationBalanceBeforeAtomic": before, "expectedDestinationIncreaseAtomic": expected} - else: - verification = {} - approvals = source.get("approvalTransactionIds") or [] + approvals = [a for a in (source.get("approvalTransactionIds") or []) if isinstance(a, str)] if src.family == "aleo": prepared = source.get("preparedTransaction") @@ -972,20 +966,17 @@ def recover(bridge, checkpoint) -> Progress: raise CheckpointInvalidError( "Bridge checkpoint contains transactions that are invalid for a prepared Aleo source route") tx_id = _assert_prepared_id(prepared.get("serializedTransaction"), str(prepared.get("transactionId"))) - return to_progress(plan, Receipt( - id=tx_id, protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, - protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared["serializedTransaction"], - **verification})) + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "preparedTransaction": prepared["serializedTransaction"], + **verification}) tx_id = source.get("transactionId") if not tx_id: raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") if cp.destination or approvals: raise CheckpointInvalidError( "Bridge checkpoint contains transactions that are invalid for an Aleo source route") - receipt = get_status(bridge, plan, Receipt( - id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, - protocol_state={"routeId": plan.route_id, **verification})) - return _finish(bridge, plan, receipt, cp.id) + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state={"routeId": plan.route_id, **verification}) if src.family == "solana": tx_id = source.get("transactionId") @@ -1002,8 +993,126 @@ def recover(bridge, checkpoint) -> Progress: state: dict[str, Any] = {"routeId": plan.route_id} if isinstance(blockhash, str) and isinstance(last_valid, str): state.update(blockhash=blockhash, lastValidBlockHeight=last_valid) - receipt = get_status(bridge, plan, Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, - source_tx_id=tx_id, protocol_state=state)) + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state=state) + + if src.family == "evm": + tx_id = source.get("transactionId") + if tx_id: + state: dict[str, Any] = {"routeId": plan.route_id} + if approvals: + state["approvalTxIds"] = approvals + return Receipt(id=tx_id, protocol=plan.protocol, status=Status.SOURCE_CONFIRMING, source_tx_id=tx_id, + protocol_state=state) + if approvals: + return Receipt(id=approvals[-1], protocol=plan.protocol, status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": approvals}) + raise CheckpointInvalidError("Bridge checkpoint contains no submitted source transaction") + + raise UnsupportedRouteError(f"Bridge checkpoint recovery is not implemented for source family {src.family!r}") + + +def _apply_destination_overlay(resolved: ResolvedRoute, cp: Checkpoint, receipt: Receipt) -> Receipt: + """Offline-only: fold a checkpoint's own destination fields (inbound xReserve) into *receipt* + without any network read — a submitted destination transaction id becomes + ``DESTINATION_CONFIRMING``, an unbroadcast prepared one becomes ``DESTINATION_ACTION_REQUIRED`` + (ready for ``complete()``). Never validates either against live chain state the way ``recover`` + does over the wire (that would need the Circle attestation, which a checkpoint never stores); + ``complete()``/``resume()`` re-verify before acting, so an offline-optimistic guess here is + never unsafe — only ever a prompt to call the verb that actually checks. + """ + destination = cp.destination or {} + if not destination: + return receipt + if resolved.route.protocol != "xreserve" or resolved.destination_chain.family != "aleo": + raise CheckpointInvalidError( + "Bridge checkpoint contains a destination transaction that is invalid for this route") + prepared_dest = destination.get("preparedTransaction") + if prepared_dest and destination.get("transactionId"): + raise CheckpointInvalidError( + "Bridge checkpoint cannot contain both prepared and submitted destination transactions") + if destination.get("transactionId"): + receipt = receipt.replace(status=Status.DESTINATION_CONFIRMING, destination_tx_id=destination["transactionId"]) + if prepared_dest: + tx_id = _assert_prepared_id(prepared_dest.get("serializedTransaction"), str(prepared_dest.get("transactionId")), + "prepared Aleo destination transaction") + receipt = receipt.replace(id=tx_id, status=Status.DESTINATION_ACTION_REQUIRED, + next_action={"kind": "xreserve-private-mint", "chainId": resolved.destination_chain.id}, + protocol_state={**receipt.protocol_state, + "preparedDestinationTransaction": prepared_dest["serializedTransaction"]}) + return receipt + + +def progress_from_checkpoint(registry: Registry, checkpoint) -> Progress: + """Pure, fully offline reconstruction of a checkpoint's ``Progress`` — no network, no signing. + + Used by ``Bridge.pending()`` instead of ``recover`` so that listing every in-flight transfer + never depends on any chain being reachable (one unreachable RPC must never hide every other + transfer). Rebuilds the ``Plan`` from the checkpoint's own intent and validates it against the + live registry exactly like ``recover`` (still raises on a bad format, version, or route + mismatch — those mean the record cannot be interpreted at all). From there, everything is + built purely from the checkpoint's stored fields (:func:`_reconstruct_source_receipt` / + :func:`_apply_destination_overlay`) — the same reconstruction ``recover`` performs before its + own live refresh. A checkpoint whose stored fields cannot be interpreted after that point + (e.g. no submitted or prepared source transaction at all) folds into a ``Progress`` with + ``next == "failed"`` and ``error`` set, instead of raising — so one malformed record can never + hide the others in a ``pending()`` listing. + """ + cp = _coerce_checkpoint(checkpoint) + if cp.version != 1 or not cp.intent or not cp.route: + raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") + plan = _plan_from_intent(registry, cp.intent) + if cp.route.get("registryVersion") != plan.registry_version: + raise RegistryVersionMismatchError( + f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " + f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") + if cp.route.get("id") != plan.route_id: + raise CheckpointInvalidError( + f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") + resolved = resolve_route(registry, plan) + try: + verification = _verification_from_delivery(cp.delivery_verification or {}) + receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) + receipt = _apply_destination_overlay(resolved, cp, receipt) + except BridgeError as exc: + receipt = Receipt(id=cp.id, protocol=plan.protocol, status=Status.FAILED, + protocol_state={"routeId": plan.route_id, "sourceError": str(exc)}) + return to_progress(plan, receipt) + + +def recover(bridge, checkpoint) -> Progress: + """Rebuild a transfer's ``Progress`` from a saved checkpoint — reads only, never signs. + + Accepts a ``Checkpoint``, its dict, or its JSON. Re-runs ``prepare`` on the + saved intent against the LIVE registry, then checks the route id and registry + version (``CheckpointInvalidError`` / ``RegistryVersionMismatchError``). + Aleo source: a proved-but-unbroadcast transaction yields ``next == "resume"`` + with no network read; a submitted one gets exactly one ``get_status`` from + ``SOURCE_CONFIRMING``. Solana: validates the blockhash pair and reads the + signature status. EVM: delegates to ``bridge.eth.recover_source`` (log + scan); inbound xReserve additionally restores a submitted or prepared + private mint. The result's ``next`` tells the caller what to do. + """ + cp = _coerce_checkpoint(checkpoint) + if cp.version != 1 or not cp.intent or not cp.route: + raise CheckpointInvalidError("Bridge checkpoint format is invalid or unsupported (version 1 required)") + plan = _plan_from_intent(bridge.registry, cp.intent) + if cp.route.get("registryVersion") != plan.registry_version: + raise RegistryVersionMismatchError( + f"Checkpoint was written against registry {cp.route.get('registryVersion')}; this client has " + f"{plan.registry_version}. Upgrade/downgrade aleo-bridge-sdk to the version that wrote it.") + if cp.route.get("id") != plan.route_id: + raise CheckpointInvalidError( + f"Bridge checkpoint route {cp.route.get('id')} does not match the prepared route {plan.route_id}") + resolved = resolve_route(bridge.registry, plan) + src, dst = resolved.source_chain, resolved.destination_chain + verification = _verification_from_delivery(cp.delivery_verification or {}) + + if src.family in ("aleo", "solana"): + receipt = _reconstruct_source_receipt(plan, resolved, cp, verification) + if receipt.status is Status.SOURCE_SUBMISSION_PENDING: + return to_progress(plan, receipt) # Aleo prepared, unbroadcast: no network read + receipt = get_status(bridge, plan, receipt) return _finish(bridge, plan, receipt, cp.id) if resolved.route.protocol == "hyperlane" and src.family == "evm": diff --git a/bridge-sdk/tests/test_client_lifecycle.py b/bridge-sdk/tests/test_client_lifecycle.py index 9fe40ace..03882e1e 100644 --- a/bridge-sdk/tests/test_client_lifecycle.py +++ b/bridge-sdk/tests/test_client_lifecycle.py @@ -9,6 +9,7 @@ so the export pin below now covers them; ``AGENTS.md`` is still Task 12's, and ``__main__.py`` is left untouched. """ +import inspect from pathlib import Path import aleo_bridge @@ -16,7 +17,7 @@ from aleo_bridge.checkpoint import FileCheckpointStore, create_checkpoint from aleo_bridge.client import Bridge from aleo_bridge.types import Receipt, Status -from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, SOL_ADDRESS, FakeBridge # The full pre-existing __all__ (plans 1-3, before this task's additive edit) — pinned so this # task's edit can only ever ADD names, never drop one (controller ruling 1). @@ -74,9 +75,12 @@ def test_execute_wait_get_status_recover_resume_complete_forward(monkeypatch): on_checkpoint=cb, proving="local", mode="signer", record="r", merkle_proof="m", gas_payment_microcredits=5, secret_nonce="1scalar", poll_seconds=2.0, timeout_seconds=3.0) seen = _spy(monkeypatch, "wait") - Bridge.wait(b, "PROGRESS", until=[Status.DELIVERY_PENDING], poll_seconds=1, timeout_seconds=2, on_update=cb) + on_err = lambda exc: None + Bridge.wait(b, "PROGRESS", until=[Status.DELIVERY_PENDING], poll_seconds=1, timeout_seconds=2, on_update=cb, + on_error=on_err, max_consecutive_errors=9) assert seen["args"] == ("PROGRESS",) and seen["kwargs"] == dict(until=[Status.DELIVERY_PENDING], poll_seconds=1, - timeout_seconds=2, on_update=cb) + timeout_seconds=2, on_update=cb, on_error=on_err, + max_consecutive_errors=9) seen = _spy(monkeypatch, "get_status") Bridge.get_status(b, "PLAN", "RECEIPT") assert seen["args"] == ("PLAN", "RECEIPT") @@ -92,17 +96,70 @@ def test_execute_wait_get_status_recover_resume_complete_forward(monkeypatch): assert seen["kwargs"] == dict(secret_nonce="7scalar", on_checkpoint=cb, proving="delegate") -def test_pending_recovers_every_stored_checkpoint(tmp_path): +class _Boom: + """Any attribute access returns a callable that raises — stands in for ``eth``/``sol`` so a + test can assert ``pending()`` never touches the network, not merely that it happened to answer + correctly this time.""" + + def __getattr__(self, name): + def raiser(*args, **kwargs): + raise AssertionError(f"Bridge.pending() must not touch the network (called .{name})") + return raiser + + +def test_pending_recovers_every_stored_checkpoint_offline(tmp_path): + # Fix round 1 (F1): pending() must not touch the network — one unreachable chain must never + # hide every other in-flight transfer. An EVM and a Solana checkpoint in the same store, with + # both side-chain modules wired to raise on ANY call, still both come back. + store = FileCheckpointStore(tmp_path) + b = FakeBridge(solana=True, checkpoints=store) + b._eth, b._sol = _Boom(), _Boom() + + evm_plan = lifecycle.prepare(b.registry, source="ethereum/wbtc", destination="aleo/wbtc", amount="0.001", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS) + store.save(create_checkpoint(evm_plan, Receipt(id="0x" + "11" * 32, protocol="hyperlane", + status=Status.SOURCE_CONFIRMING, source_tx_id="0x" + "11" * 32, + protocol_state={"routeId": evm_plan.route_id}), b.registry)) + sol_plan = lifecycle.prepare(b.registry, source="solana/sol", destination="aleo/sol", amount="0.000000001", + recipient=ALEO_RECIPIENT, sender=SOL_ADDRESS) + store.save(create_checkpoint(sol_plan, Receipt(id="sig", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="sig", protocol_state={"routeId": sol_plan.route_id}), + b.registry)) + + out = Bridge.pending(b) + assert len(out) == 2 and all(p.next == "wait" for p in out) and b.calls == [] + assert {p.receipt.source_tx_id for p in out} == {"0x" + "11" * 32, "sig"} + assert Bridge.pending(FakeBridge(ethereum=False)) == [] + + +def test_pending_folds_a_malformed_checkpoint_into_a_failed_entry(tmp_path): + # F1: a checkpoint that cannot be interpreted (well-formed JSON/route, but nothing to build a + # receipt from — e.g. a truncated file that lost its "source" block) must not hide the + # healthy checkpoints alongside it in the same store. store = FileCheckpointStore(tmp_path) b = FakeBridge(ethereum=False, checkpoints=store) plan = lifecycle.prepare(b.registry, source="aleo/eth", destination="ethereum/eth", amount="0.000000000000000001", recipient=EVM_ADDRESS) - for tx in ("at1one", "at1two"): - store.save(create_checkpoint(plan, Receipt(id=tx, protocol="hyperlane", status=Status.SOURCE_CONFIRMING, - source_tx_id=tx, protocol_state={"routeId": plan.route_id}), b.registry)) + store.save(create_checkpoint(plan, Receipt(id="at1good", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + source_tx_id="at1good", protocol_state={"routeId": plan.route_id}), + b.registry)) + store.save(create_checkpoint(plan, Receipt(id="at1ghost", protocol="hyperlane", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": plan.route_id}), b.registry)) # no source tx at all + out = Bridge.pending(b) - assert [p.receipt.source_tx_id for p in out] == ["at1one", "at1two"] and all(p.next == "wait" for p in out) - assert Bridge.pending(FakeBridge(ethereum=False)) == [] + assert len(out) == 2 + good, bad = out + assert good.next == "wait" and good.receipt.source_tx_id == "at1good" + assert bad.next == "failed" and bad.error is not None and "no submitted source transaction" in bad.error + + +def test_bridge_verb_signatures_are_a_superset_of_the_lifecycle_verb_they_forward_to(): + # F2 guard: a future edit that drops a kwarg from a Bridge verb (without dropping it from the + # matching lifecycle verb too) fails loudly here instead of silently losing a caller option. + for name in ("quote", "execute", "get_status", "wait", "recover", "resume", "complete"): + bridge_params = set(inspect.signature(getattr(Bridge, name)).parameters) + lifecycle_params = set(inspect.signature(getattr(lifecycle, name)).parameters) - {"bridge"} + assert lifecycle_params <= bridge_params, name def test_public_exports_pin_pre_existing_set_and_add_lifecycle_names(): From a7d69f175ef5f20cf46b134c231416861f6ff324 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 13:51:15 -0400 Subject: [PATCH 84/94] fix(bridge-sdk): agent tools return checkpoints from reads, redact record previews, copy schemas, guide recovery on write errors --- bridge-sdk/python/aleo_bridge/agent.py | 125 +++++++++++++++----- bridge-sdk/tests/fakes/fake_bridge.py | 13 +- bridge-sdk/tests/test_agent.py | 157 ++++++++++++++++++++++++- 3 files changed, 261 insertions(+), 34 deletions(-) diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index 3de300fb..d647e9b4 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -13,7 +13,8 @@ * **Secrets never leave the process.** ``_serialize`` drops the private-mint secret, the Circle attestation body and the proved transaction bytes from any - receipt it renders, and no tool ever echoes its own arguments back. (The + receipt it renders, a previewed program call renders record-shaped inputs as + ``""``, and no tool ever echoes its own arguments back. (The xReserve *hook data* — a public commitment, not the secret that opens it — stays on the quote and inside the checkpoint, because ``lifecycle.resume`` refuses to resume a deposit whose checkpoint has lost it.) @@ -29,8 +30,10 @@ """ from __future__ import annotations +import copy import dataclasses import enum +import re from typing import Any, Callable from . import lifecycle @@ -119,6 +122,29 @@ def _serialize(value: Any, registry: Registry | None = None) -> Any: return str(value) +#: A record, in any form an Aleo call input can carry it: the plaintext ``{ owner: … }`` a record +#: selection returns (``privacy.py``'s ``select_record``), the ``record1…`` ciphertext, a +#: ``x.record`` locator, or any plaintext carrying a nonce / a credits amount. A record IS the +#: private balance — showing one to a model (or writing it into a transcript) spends its privacy. +_RECORD_SHAPED = re.compile(r""" + (^record1[a-z0-9]{8,}) # record ciphertext + | (\.record\b) # a record locator + | (^\{\s*owner\s*:) # record plaintext, as select_record returns it + | (\b_nonce\s*:) # …or anything else carrying a record's nonce + | (\bmicrocredits\s*:) # …or a credits record's amount +""", re.IGNORECASE | re.VERBOSE) + + +def _summarize_input(value: Any) -> str: + """One program-call input, rendered for a human or a model: record-shaped → ``""``. + + Everything public — the amount literal, the recipient address, a Merkle path — passes through, + so the preview still says what the call does. + """ + text = value if isinstance(value, str) else str(value) + return "" if _RECORD_SHAPED.search(text.strip()) else text + + def _redacted_state(state: Any) -> Any: if not isinstance(state, dict): return _serialize(state) @@ -191,7 +217,9 @@ def _missing_nonce(what: str) -> dict[str, Any]: _QUOTE_REQUIRED = ["source", "destination", "amount", "recipient"] _CONFIRM = {"confirm": {**_B, "description": "Set true to move funds. Without it the quote is returned and nothing is submitted."}} _CHECKPOINT = {"checkpoint": {"type": "object", - "description": "The checkpoint dict returned by bridge_execute / bridge_pending / bridge_get_progress."}} + "description": "The checkpoint dict returned by bridge_get_progress, by an entry of " + "bridge_pending, or by bridge_execute / bridge_resume / bridge_complete " + "(including the one an interrupted write hands back with next='recover')."}} def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: @@ -220,6 +248,26 @@ def _confirmation(**payload: Any) -> dict[str, Any]: return {"confirmation_required": True, **payload, "how_to_confirm": HOW_TO_CONFIRM} +def _write(b: Any, call: Callable[[list[Any]], Any]) -> dict[str, Any]: + """Run one fund-moving verb, collecting its checkpoints, and render either outcome. + + Success is ``{"progress", "checkpoint"}``. A failure is never a retry cue: every write here is + single-use and a lost RPC answer is ambiguous, so the error comes back with ``next: "recover"``, + how-to-fix pointing at ``bridge_get_progress``, and the last checkpoint that made it out — + which, for a write interrupted after proving, is the only copy of those bytes. + """ + seen: list[Any] = [] + try: + progress = call(seen) + except BridgeError as exc: + payload = _error_payload(exc, next="recover") + payload["how_to_fix"] = RECOVER_HOW_TO_FIX + if seen: + payload["checkpoint"] = _serialize(seen[-1], b.registry) + return payload + return _with_checkpoint(b, progress) + + # ── reads ───────────────────────────────────────────────────────────────────── def _h_status(b, a): @@ -246,12 +294,35 @@ def _h_quote(b, a): def _h_get_progress(b, a): - return _serialize(lifecycle.recover(b, a["checkpoint"]), b.registry) + # A checkpoint comes back with the progress: an agent that started from a stale one (or from + # bridge_pending) can hand this one straight to bridge_resume / bridge_complete. + return _with_checkpoint(b, lifecycle.recover(b, a["checkpoint"])) def _h_pending(b, a): + """Every stored checkpoint with the ``Progress`` reconstructed for it — offline, one entry each. + + Reconstruction is :func:`lifecycle.progress_from_checkpoint`, exactly what ``Bridge.pending()`` + runs per record (no network read, so one unreachable chain can never hide the others). It is + called here rather than through ``Bridge.pending()`` because that verb returns bare ``Progress`` + objects: it neither pairs each one with the checkpoint that produced it — which is what the + recovery tools take back, and must be the STORED record, not one re-derived from a receipt an + offline reconstruction may have flattened — nor reports the records it could not interpret at + all (it drops them). Here such a record becomes one error entry naming its checkpoint id, and + the healthy entries still come back. + """ store = getattr(b, "checkpoints", None) - return [_serialize(lifecycle.recover(b, cp), b.registry) for cp in store.list()] if store is not None else [] + if store is None: + return [] + out: list[dict[str, Any]] = [] + for cp in store.list(): + try: + progress = lifecycle.progress_from_checkpoint(b.registry, cp) + except BridgeError as exc: + out.append(_error_payload(exc, checkpoint_id=cp.id)) + continue + out.append({"progress": _serialize(progress, b.registry), "checkpoint": cp.to_dict()}) + return out # ── writes (confirm-gated) ──────────────────────────────────────────────────── @@ -263,27 +334,17 @@ def _h_execute(b, a): quote = lifecycle.quote(b, **_quote_kwargs(a)) if not a.get("confirm"): return _confirmation(quote=_serialize(quote, b.registry)) - seen: list[Checkpoint] = [] - try: - progress = lifecycle.execute( - b, quote.plan, on_checkpoint=seen.append, mode=a.get("mode"), proving=a.get("proving", "delegate"), - gas_payment_microcredits=a.get("gas_payment_microcredits"), secret_nonce=a.get("secret_nonce")) - except BridgeError as exc: - # A source call is single-use and a lost RPC response is ambiguous: never retry execute, - # hand back whatever checkpoint made it out so the model can recover from it. - payload = _error_payload(exc, next="recover") - payload["how_to_fix"] = RECOVER_HOW_TO_FIX - if seen: - payload["checkpoint"] = seen[-1].to_dict() - return payload - return _with_checkpoint(b, progress) + return _write(b, lambda seen: lifecycle.execute( + b, quote.plan, on_checkpoint=seen.append, mode=a.get("mode"), proving=a.get("proving", "delegate"), + gas_payment_microcredits=a.get("gas_payment_microcredits"), secret_nonce=a.get("secret_nonce"))) def _h_resume(b, a): - progress = lifecycle.recover(b, a["checkpoint"]) + progress = lifecycle.recover(b, a["checkpoint"]) # reads only if not a.get("confirm"): return _confirmation(progress=_serialize(progress, b.registry)) - return _with_checkpoint(b, lifecycle.resume(b, progress, secret_nonce=a.get("secret_nonce"))) + return _write(b, lambda seen: lifecycle.resume(b, progress, on_checkpoint=seen.append, + secret_nonce=a.get("secret_nonce"))) def _has_prepared_destination(progress: Any) -> bool: @@ -299,15 +360,17 @@ def _h_complete(b, a): return _missing_nonce("the deposit this mint finishes") if not a.get("confirm"): return _confirmation(progress=_serialize(progress, b.registry)) - return _with_checkpoint(b, lifecycle.complete(b, progress, secret_nonce=secret_nonce)) + return _write(b, lambda seen: lifecycle.complete(b, progress, on_checkpoint=seen.append, + secret_nonce=secret_nonce)) def _privacy(b, a, direction: str): kwargs = dict(asset=a["asset"], amount=a.get("amount"), amount_atomic=a.get("amount_atomic")) call = b.shield(**kwargs) if direction == "shield" else b.unshield(**kwargs) if not a.get("confirm"): + # An unshield's inputs carry the selected record's plaintext — the private balance itself. return _confirmation(call={"program": call.program_id, "function": call.function_name, - "inputs": list(call.inputs)}) + "inputs": [_summarize_input(i) for i in call.inputs]}) return _serialize(call.delegate(), b.registry) @@ -341,11 +404,14 @@ def _h_unshield(b, a): "(mint_mode='private') must carry the user's own secret_nonce — there is no default.", _schema(_QUOTE_PROPS, _QUOTE_REQUIRED), _h_quote), ("bridge_get_progress", - "Recover a transfer's state from a checkpoint (reads only). progress.next tells what to do: wait (call again " - "later), resume (bridge_resume), complete (bridge_complete), done, failed.", + "Recover a transfer's state from a checkpoint (reads only). Returns {progress, checkpoint}: progress.next tells " + "what to do — wait (call again later), resume (bridge_resume), complete (bridge_complete), done, failed — and " + "the checkpoint is the fresh one to pass to whichever of those you call.", _schema(_CHECKPOINT, ["checkpoint"]), _h_get_progress), ("bridge_pending", - "Every in-flight transfer in this profile's checkpoint store, recovered to current progress.", + "Every in-flight transfer in this profile's checkpoint store, one {progress, checkpoint} entry each, " + "reconstructed offline (no chain read, so its progress can lag: bridge_get_progress refreshes one against live " + "state). A record too damaged to interpret comes back as {error, checkpoint_id} in its place.", _schema({}, []), _h_pending), ] @@ -389,9 +455,14 @@ def _h_unshield(b, a): def bridge_tools(include_writes: bool = True) -> list[dict[str, Any]]: - """Tool definitions (Claude API ``tools=`` shape); ``include_writes=False`` keeps only reads.""" + """Tool definitions (Claude API ``tools=`` shape); ``include_writes=False`` keeps only reads. + + Every call returns a fresh deep copy: a caller that tailors a schema (or a framework that + annotates one in place) can never edit the module's own table out from under everyone else. + """ tools = _READ_TOOLS + (_WRITE_TOOLS if include_writes else []) - return [{"name": name, "description": desc, "input_schema": schema} for name, desc, schema, _ in tools] + return [{"name": name, "description": desc, "input_schema": copy.deepcopy(schema)} + for name, desc, schema, _ in tools] def dispatch_tool(bridge: Any, name: str, args: dict[str, Any] | None = None) -> Any: diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index 9eae11a5..23840c0a 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -33,6 +33,13 @@ from tests.conftest import default_mappings ALEO_RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +#: What ``PrivacyModule.unshield`` really puts into ``call.inputs``: the selected record's +#: PLAINTEXT (see ``privacy.py`` — ``select_record`` returns the plaintext string). Anything that +#: renders a built call for a human or a model has to redact it. +RECORD_PLAINTEXT = ( + "{ owner: aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n.private, " + "amount: 100000000u128.private, " + "_nonce: 5749463759923163832671233077408835222301563867853163045949890371815825289938group.public }") EVM_ADDRESS = "0x0000000000000000000000000000000000000001" SOL_ADDRESS = "11111111111111111111111111111111" OUTBOUND = {"aleo/eth": "ethereum/eth", "aleo/wbtc": "ethereum/wbtc", @@ -63,6 +70,9 @@ def delegate_prepared(self, account=None, **fee) -> PreparedTx: def submit_prepared(self, prepared: PreparedTx, *, wait=True, wait_timeout=180.0): self.fake.events.append(("submit", prepared.transaction_id, wait)) + if self.fake.submit_error is not None: + # The bytes are already checkpointed and may be on the wire: an ambiguous broadcast. + raise self.fake.submit_error self.fake.submitted.append(prepared.serialized) return self._make(prepared.transaction_id) @@ -452,6 +462,7 @@ def __init__(self, *, environment="mainnet", ethereum=True, solana=False, checkp self.events: list[tuple] = [] # ordered side effects (prove/submit/checkpoint...) self.calls: list[tuple] = [] # module method calls with kwargs self.submitted: list[str] = [] + self.submit_error: Exception | None = None # raised by submit_prepared, after the checkpoint self._tx = 0 self.aleo = _ConftestFakeAleo(mappings=default_mappings(), network_name=environment) self.hyperlane = FakeHyperlane(self) @@ -493,7 +504,7 @@ def shield(self, asset, *, amount=None, amount_atomic=None, recipient=None) -> F def unshield(self, asset, *, amount=None, amount_atomic=None, record=None, merkle_proof=None, recipient=None): self.calls.append(("unshield", dict(asset=asset, amount=amount, amount_atomic=amount_atomic))) - return FakeAleoCall(self, "arc20_eth.aleo", "unshield", ["", f"{amount_atomic}u128"], self.next_tx_id(), + return FakeAleoCall(self, "arc20_eth.aleo", "unshield", [RECORD_PLAINTEXT, f"{amount_atomic}u128"], self.next_tx_id(), lambda tx: PrivacyReceipt(tx, asset, str(amount or amount_atomic), amount_atomic or 0, "unshield")) def status(self) -> BridgeStatus: diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py index 04275cca..163a0f1a 100644 --- a/bridge-sdk/tests/test_agent.py +++ b/bridge-sdk/tests/test_agent.py @@ -9,12 +9,12 @@ import pytest -from aleo_bridge.agent import _serialize, bridge_tools, dispatch_tool -from aleo_bridge.checkpoint import FileCheckpointStore +from aleo_bridge.agent import _serialize, _summarize_input, bridge_tools, dispatch_tool +from aleo_bridge.checkpoint import Checkpoint, FileCheckpointStore, create_checkpoint from aleo_bridge.errors import BridgeError from aleo_bridge.lifecycle import prepare from aleo_bridge.types import Fee, Receipt, Status -from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge +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"} @@ -161,11 +161,75 @@ def test_get_progress_and_pending(tmp_path): "amount": "0.000000000000000001", "recipient": EVM_ADDRESS, "gas_payment_microcredits": 1, "confirm": True}) assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" - progress = dispatch_tool(b, "bridge_get_progress", {"checkpoint": out["checkpoint"]}) - assert progress["next"] == "wait" and progress["receipt"]["source_tx_id"] == "at1fake1" + recovered = dispatch_tool(b, "bridge_get_progress", {"checkpoint": out["checkpoint"]}) + assert recovered["progress"]["next"] == "wait" + assert recovered["progress"]["receipt"]["source_tx_id"] == "at1fake1" + # a read hands back a checkpoint too: the model never has to keep the one execute returned + assert recovered["checkpoint"]["receiptId"] == "at1fake1" + assert recovered["checkpoint"]["route"]["id"] == out["checkpoint"]["route"]["id"] assert b.submitted.count(b.submitted[0]) == 1 # recover never rebroadcasts pending = dispatch_tool(b, "bridge_pending", {}) - assert [p["receipt"]["id"] for p in pending] == ["at1fake1"] + assert [p["progress"]["receipt"]["id"] for p in pending] == ["at1fake1"] + assert [p["checkpoint"] for p in pending] == [cp.to_dict() for cp in store.list()] + json.dumps(pending) + + +def test_pending_hands_back_the_stored_checkpoint_that_restarts_the_flow(tmp_path): + """The restart path: a fresh process lists pending transfers and resumes one from the + checkpoint the listing echoed — never a checkpoint re-derived from a recovered receipt.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + cp, serialized = _aleo_out_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 1 and entries[0]["progress"]["next"] == "resume" + assert entries[0]["checkpoint"] == Checkpoint.from_dict(cp).to_dict() # stored form, verbatim + out = dispatch_tool(b, "bridge_resume", {"checkpoint": entries[0]["checkpoint"], "confirm": True}) + assert out["progress"]["receipt"]["status"] == "SOURCE_CONFIRMING" and b.aleo.submitted == [serialized] + + +def test_pending_checkpoint_completes_a_mint_whose_offline_progress_still_says_wait(tmp_path): + """``bridge_pending`` is offline, so its progress can lag the chain (here: 'wait' while Circle + has in fact attested). The echoed checkpoint is still exactly what ``bridge_complete`` takes.""" + store = FileCheckpointStore(tmp_path) + b = FakeBridge(environment="testnet", checkpoints=store) + cp = _inbound_private_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 1 and entries[0]["progress"]["next"] == "wait" # offline view + out = dispatch_tool(b, "bridge_complete", {"checkpoint": entries[0]["checkpoint"], + "secret_nonce": NONCE, "confirm": True}) + assert out["progress"]["receipt"]["status"] == "DESTINATION_CONFIRMING" + assert NONCE not in json.dumps(out) + + +def test_pending_reports_a_malformed_record_instead_of_collapsing_the_list(tmp_path): + store = FileCheckpointStore(tmp_path) + b = FakeBridge(ethereum=False, checkpoints=store) + cp, _ = _aleo_out_checkpoint(b) + store.save(Checkpoint.from_dict(cp)) + (tmp_path / "at1broken.json").write_text(json.dumps( + {"version": 1, "receiptId": "at1broken", "intent": {}, "route": {"id": "x", "registryVersion": "y"}}), + encoding="utf-8") + + entries = dispatch_tool(b, "bridge_pending", {}) + assert len(entries) == 2 + healthy = [e for e in entries if "progress" in e] + broken = [e for e in entries if "error" in e] + assert len(healthy) == 1 and healthy[0]["checkpoint"] == Checkpoint.from_dict(cp).to_dict() + assert len(broken) == 1 and broken[0]["checkpoint_id"] == "at1broken" + assert broken[0]["error_type"] == "CheckpointInvalidError" and broken[0]["error"] + assert "progress" not in broken[0] + json.dumps(entries) + + +def test_checkpoint_property_names_exactly_the_tools_that_return_one(): + tools = {t["name"]: t for t in bridge_tools()} + description = tools["bridge_get_progress"]["input_schema"]["properties"]["checkpoint"]["description"] + returns_one = {"bridge_execute", "bridge_resume", "bridge_complete", "bridge_get_progress", "bridge_pending"} + assert {name for name in tools if name in description} == returns_one def test_resume_and_complete_gates(): @@ -196,6 +260,53 @@ def test_shield_unshield_gates(): assert out["direction"] == "unshield" and out["amount_atomic"] == 1 +def test_privacy_previews_never_echo_a_record(tmp_path): + """An unshield call's inputs carry the selected record's PLAINTEXT — the private balance + itself. The preview an agent shows a user must summarize it, never echo it.""" + b = FakeBridge() + out = dispatch_tool(b, "bridge_unshield", {"asset": "aleo/eth", "amount_atomic": 1}) + blob = json.dumps(out) + assert out["confirmation_required"] is True + assert out["call"] == {"program": "arc20_eth.aleo", "function": "unshield", + "inputs": ["", "1u128"]} # amount literal kept + assert RECORD_PLAINTEXT not in blob and "_nonce" not in blob and "owner:" not in blob + # shielding has no record to leak: its literals pass through untouched + shield = dispatch_tool(b, "bridge_shield", {"asset": "aleo/eth", "amount_atomic": 1}) + assert shield["call"]["inputs"] == ["1u128"] + + +@pytest.mark.parametrize("value", [ + RECORD_PLAINTEXT, + "{ owner: aleo1abc.private, microcredits: 1500000u64.private }", + "record1qyqsqpe2szk2wwwq56akkwx586hkndl3r8vzdwve32lm7elvphh37rsyqyxx66trwfhkxun9v35hguerqqpqzq" + "8tc0y3cc45vqs0vzcqmqxqwqsy3y6qw6w4vdkvq3qsqrqwqsyq", + "at1abcdefghijklmnop.record", +]) +def test_summarizer_redacts_record_shaped_inputs(value): + assert _summarize_input(value) == "" + + +@pytest.mark.parametrize("value", ["1u128", ALEO_RECIPIENT, "arc20_eth.aleo", "transfer_private_to_public", + "{ siblings: [ 0field ], leaf_index: 0u32 }", "true", "0u64"]) +def test_summarizer_keeps_public_literals(value): + assert _summarize_input(value) == value + + +def test_bridge_tools_returns_deep_copies(): + first = bridge_tools() + first[0]["description"] = "mutated" + first[0]["input_schema"]["properties"]["injected"] = {"type": "string"} + quote = next(t for t in first if t["name"] == "bridge_quote") + quote["input_schema"]["required"].append("injected") + + second = bridge_tools() + assert second[0]["description"] != "mutated" + assert "injected" not in second[0]["input_schema"]["properties"] + fresh_quote = next(t for t in second if t["name"] == "bridge_quote") + assert fresh_quote["input_schema"]["required"] == ["source", "destination", "amount", "recipient"] + assert fresh_quote["input_schema"]["properties"] is not quote["input_schema"]["properties"] + + def test_unknown_tool(): with pytest.raises(ValueError, match="Unknown bridge tool"): dispatch_tool(FakeBridge(), "nope", {}) @@ -289,3 +400,37 @@ def test_ambiguous_send_surfaces_recover_guidance_and_the_checkpoint(): assert out["checkpoint"]["receiptId"] == "0x" + "11" * 32 assert out["checkpoint"]["route"]["id"] == route_id json.dumps(out) + + +def test_resume_write_error_surfaces_recover_guidance_and_the_checkpoint(): + b = FakeBridge() + plan = prepare(b.registry, source="ethereum/usdc", destination="aleo/usdcx", amount="2", + recipient=ALEO_RECIPIENT, sender=EVM_ADDRESS, mint_mode="private") + approval = "0x" + "11" * 32 + receipt = Receipt(id=approval, protocol="xreserve", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [approval], + "sourceSender": EVM_ADDRESS, + "hookData": "0x" + b.eth.hook_data.hex()}) + b.eth.recover_result = receipt # history still has no deposit + b.eth.intermediates = [receipt] # re-checkpointed before the deposit goes out + b.eth.send_error = BridgeError("Ethereum deposit may already be broadcast: the RPC response was lost") + cp = create_checkpoint(plan, receipt, b.registry).to_dict() + + out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True}) + assert out["error_type"] == "BridgeError" and "may already be broadcast" in out["error"] + assert out["next"] == "recover" and "bridge_get_progress" in out["how_to_fix"] + assert out["checkpoint"]["receiptId"] == approval and out["checkpoint"]["route"]["id"] == plan.route_id + assert NONCE not in json.dumps(out) + + +def test_complete_write_error_surfaces_recover_guidance_and_the_prepared_checkpoint(): + b = FakeBridge(environment="testnet") + cp = _inbound_private_checkpoint(b) + b.submit_error = BridgeError("Aleo mint at1fake1 may already be broadcast: the node answer was lost") + + out = dispatch_tool(b, "bridge_complete", {"checkpoint": cp, "secret_nonce": NONCE, "confirm": True}) + assert out["error_type"] == "BridgeError" and out["next"] == "recover" + assert "bridge_get_progress" in out["how_to_fix"] + # the pre-broadcast checkpoint made it out: the proved mint can be rebroadcast, never re-proved + assert out["checkpoint"]["destination"]["preparedTransaction"]["transactionId"] == "at1fake1" + assert NONCE not in json.dumps(out) From 59a88b478d6e166d084f19dbf8d07d637da36037 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 14:36:40 -0400 Subject: [PATCH 85/94] feat(bridge-sdk): MCP stdio server over the agent tools --- bridge-sdk/python/aleo_bridge/mcp.py | 132 +++++++++++++++++++++++++++ bridge-sdk/tests/test_mcp.py | 84 +++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 bridge-sdk/python/aleo_bridge/mcp.py create mode 100644 bridge-sdk/tests/test_mcp.py diff --git a/bridge-sdk/python/aleo_bridge/mcp.py b/bridge-sdk/python/aleo_bridge/mcp.py new file mode 100644 index 00000000..3afaf1ec --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/mcp.py @@ -0,0 +1,132 @@ +"""MCP server exposing the bridge lifecycle as tools (the ``[mcp]`` extra). + +Run: ``python -m aleo_bridge.mcp`` + +Uses the low-level ``mcp.server.Server`` (not FastMCP) so each tool advertises the exact JSON +schema from :func:`~aleo_bridge.agent.bridge_tools`. Tools run against the synchronous +:class:`~aleo_bridge.client.Bridge` in a worker thread. Writes stay behind ``confirm: true`` +exactly as in ``agent.py`` — the MCP transport adds no privileges of its own. + +Every ``mcp.*`` import here is lazy: importing this module (and ``import aleo_bridge``) never +requires the ``mcp`` package, and the first call into it that actually needs ``mcp`` raises the +SDK's own :class:`~aleo_bridge.errors.MissingExtraError` naming the real extra +(``pip install 'aleo-bridge-sdk[mcp]'``) instead of a bare ``ImportError`` from deep inside this +module. + +Environment (all read by ``Bridge.from_env`` — nothing here reads the environment directly, and +nothing here logs key material): + BRIDGE_PRIVATE_KEY Aleo signer (required) + ALEO_ENDPOINT / ALEO_NETWORK / ALEO_API_KEY / ALEO_CONSUMER_ID + ETHEREUM_RPC_URL / EVM_PRIVATE_KEY Ethereum connection (both or neither) + SOLANA_RPC_URL / SOLANA_PRIVATE_KEY Solana connection + BRIDGE_CHECKPOINT_DIR optional FileCheckpointStore directory +""" +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from .agent import bridge_tools, dispatch_tool +from .errors import MissingExtraError + +if TYPE_CHECKING: # pragma: no cover - typing only, never imported at runtime + from mcp.server import Server + from mcp.types import TextContent, Tool + +_FEATURE = "the MCP server" + + +def _mcp_types() -> tuple[Any, Any]: + """``(Tool, TextContent)``, or :class:`MissingExtraError` when the extra is not installed.""" + try: + from mcp.types import TextContent, Tool + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + return Tool, TextContent + + +def _mcp_server_cls() -> Any: + """``mcp.server.Server``, or :class:`MissingExtraError` when the extra is not installed.""" + try: + from mcp.server import Server + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + return Server + + +def tool_definitions() -> "list[Tool]": + """The agent tools as MCP ``Tool`` objects with their exact schemas.""" + Tool, _TextContent = _mcp_types() + return [Tool(name=t["name"], description=t["description"], inputSchema=t["input_schema"]) + for t in bridge_tools()] + + +async def call_tool(bridge: Any, name: str, arguments: dict[str, Any]) -> "list[TextContent]": + """Execute one tool in a worker thread; result as JSON text content. + + Runs the synchronous ``dispatch_tool`` off the event loop so a slow chain read never blocks + other in-flight MCP requests. ``dispatch_tool`` already renders every ``BridgeError`` as a + JSON-serializable dict and holds writes behind ``confirm: true`` — this function adds nothing + beyond the thread hop and the JSON encoding. + """ + _Tool, TextContent = _mcp_types() + from anyio import to_thread + + result = await to_thread.run_sync(lambda: dispatch_tool(bridge, name, arguments)) + return [TextContent(type="text", text=json.dumps(result))] + + +def build_server(bridge: Any, *, name: str = "aleo-bridge") -> "Server": + """An MCP server with every agent tool registered against *bridge*. + + No second tool list: both handlers below delegate straight to :func:`tool_definitions` / + :func:`call_tool`, which read the same table :func:`~aleo_bridge.agent.bridge_tools` and + :func:`~aleo_bridge.agent.dispatch_tool` use everywhere else. + """ + Server = _mcp_server_cls() + server: Any = Server(name) + + @server.list_tools() + async def _list_tools() -> "list[Tool]": + return tool_definitions() + + @server.call_tool() + async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> "list[TextContent]": + return await call_tool(bridge, tool_name, arguments) + + return server + + +def serve(bridge: Any) -> None: + """Serve the bridge tools over stdio until the client disconnects (blocking).""" + try: + import anyio + from mcp.server.stdio import stdio_server + except ImportError as exc: + raise MissingExtraError("mcp", _FEATURE) from exc + + server = build_server(bridge) + + async def _run() -> None: + async with stdio_server() as (read, write): + await server.run(read, write, server.create_initialization_options()) + + anyio.run(_run) + + +def main() -> None: + """``python -m aleo_bridge.mcp``: bind the bridge from the environment and serve. + + ``Bridge.from_env()`` already reads ``BRIDGE_PRIVATE_KEY``, the EVM/Solana keys and their + aliases, and ``BRIDGE_CHECKPOINT_DIR`` — this function never re-reads or logs any of them. + """ + from .client import Bridge + + serve(Bridge.from_env()) + + +if __name__ == "__main__": + main() + + +__all__ = ["tool_definitions", "call_tool", "build_server", "serve", "main"] diff --git a/bridge-sdk/tests/test_mcp.py b/bridge-sdk/tests/test_mcp.py new file mode 100644 index 00000000..0b52fcf2 --- /dev/null +++ b/bridge-sdk/tests/test_mcp.py @@ -0,0 +1,84 @@ +"""MCP stdio server over the agent tools — schema fidelity, dispatch, and the confirm gate. + +Every test here exercises the module's functions directly (``tool_definitions`` / ``call_tool`` / +the handlers ``build_server`` registers) against a :class:`FakeBridge`. None of them start a real +stdio server: that would require a live client on the other end of the pipe, which is exactly what +the low-level ``mcp.server.Server`` API lets us skip in tests. +""" +import json +import sys + +import pytest + +mcp = pytest.importorskip("mcp") + +from mcp import types # noqa: E402 + +from aleo_bridge.agent import bridge_tools # noqa: E402 +from aleo_bridge.errors import MissingExtraError # noqa: E402 +from aleo_bridge.mcp import build_server, call_tool, tool_definitions # noqa: E402 +from tests.fakes.fake_bridge import ALEO_RECIPIENT, FakeBridge # noqa: E402 + + +def test_tool_definitions_carry_exact_schemas(): + defs = {t.name: t for t in tool_definitions()} + expected = {t["name"]: t for t in bridge_tools()} + assert set(defs) == set(expected) + assert defs["bridge_execute"].inputSchema == expected["bridge_execute"]["input_schema"] + assert "confirm" in defs["bridge_execute"].inputSchema["properties"] + for name, tool in defs.items(): + assert tool.description == expected[name]["description"] + + +def test_build_server_constructs(): + assert build_server(FakeBridge()).name == "aleo-bridge" + + +async def test_call_tool_dispatches_and_serializes(): + b = FakeBridge() + out = await call_tool(b, "bridge_quote", {"source": "ethereum/usdc", "destination": "aleo/usdcx", + "amount": "2", "recipient": ALEO_RECIPIENT}) + assert out[0].type == "text" + assert json.loads(out[0].text)["kind"] == "evm-xreserve" + gated = await call_tool(b, "bridge_execute", {"source": "ethereum/usdc", "destination": "aleo/usdcx", + "amount": "2", "recipient": ALEO_RECIPIENT}) + assert json.loads(gated[0].text)["confirmation_required"] is True and b.events == [] + + +async def test_build_server_handlers_list_and_dispatch_through_the_real_server_wiring(): + """Reach the exact coroutines ``@server.list_tools()``/``@server.call_tool()`` registered — + not just the module-level helpers they delegate to — and confirm the write gate still holds + when a tool call is routed through the server object.""" + b = FakeBridge() + server = build_server(b) + + list_result = await server.request_handlers[types.ListToolsRequest](types.ListToolsRequest()) + names = {t.name for t in list_result.root.tools} + assert names == {t["name"] for t in bridge_tools()} + + call_result = await server.request_handlers[types.CallToolRequest]( + types.CallToolRequest(params=types.CallToolRequestParams( + name="bridge_execute", + arguments={"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", + "recipient": ALEO_RECIPIENT}))) + payload = json.loads(call_result.root.content[0].text) + assert payload["confirmation_required"] is True + assert b.events == [] + + +def test_missing_extra_error_without_mcp_installed(monkeypatch): + """``tool_definitions``/``build_server`` import ``mcp`` lazily: without the extra installed, + calling them fails with the SDK's own ``MissingExtraError`` (naming the real ``mcp`` extra from + ``pyproject.toml``), never a bare ``ImportError`` from deep inside the module.""" + for mod in ("mcp", "mcp.types", "mcp.server", "mcp.server.stdio"): + monkeypatch.setitem(sys.modules, mod, None) + + from aleo_bridge import mcp as bridge_mcp + + with pytest.raises(MissingExtraError) as exc_info: + bridge_mcp.tool_definitions() + assert exc_info.value.extra == "mcp" + assert "aleo-bridge-sdk[mcp]" in str(exc_info.value) + + with pytest.raises(MissingExtraError): + bridge_mcp.build_server(FakeBridge()) From a5ea78e3d48320b90733180b9de42014993fcecc Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 14:44:43 -0400 Subject: [PATCH 86/94] feat(bridge-sdk): generate AGENTS.md from docstrings; python -m aleo_bridge prints the guide --- bridge-sdk/AGENTS.md | 331 ++++++++++++++++++++++ bridge-sdk/codegen/gen_context.py | 219 ++++++++++++++ bridge-sdk/python/aleo_bridge/AGENTS.md | 331 ++++++++++++++++++++++ bridge-sdk/python/aleo_bridge/__main__.py | 9 +- bridge-sdk/tests/test_client.py | 9 + bridge-sdk/tests/test_gen_context.py | 49 ++++ 6 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 bridge-sdk/AGENTS.md create mode 100644 bridge-sdk/codegen/gen_context.py create mode 100644 bridge-sdk/python/aleo_bridge/AGENTS.md create mode 100644 bridge-sdk/tests/test_gen_context.py diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md new file mode 100644 index 00000000..93291cb0 --- /dev/null +++ b/bridge-sdk/AGENTS.md @@ -0,0 +1,331 @@ +# aleo-bridge — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client that moves assets between Aleo, Ethereum and Solana +over the reviewed Hyperlane warp routes and Circle xReserve deployments +(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`). +MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as +tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas. +Registry version `2026-08-31.solana-deposits.1`. + +## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked) + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +``` + +### `from_env(**overrides: 'Any') -> "'Bridge'"` + +Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints. + +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None) -> "'Bridge'"` + +The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when +creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. + +### `status(self) -> 'BridgeStatus'` + +Read-only re-orientation: addresses and public balances of every registry asset per configured chain. +Plan 4 fills ``pending`` from the checkpoint store. + +### `quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, protocol: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` + +Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + +``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` +tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of +``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is +the destination-chain address. ``mint_mode`` (xReserve into Aleo only): +``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` +— you finish it yourself with ``complete`` and must keep ``secret_nonce``. +Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / +solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with +``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the +user fees + amount before ``execute``. + +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` + +Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + +Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to +``on_checkpoint`` (and the bound store) at every boundary — including +AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is +resumable without proving twice. ``proving`` is ``"delegate"`` (DPS) or +``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or +``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default +private; ``record``/``merkle_proof`` optional — the SDK selects a record +and computes the exclusion proof). The Hyperlane hook payment is +re-quoted right before proving unless ``gas_payment_microcredits`` is +pinned. Irreversible once the source step is broadcast: afterwards use +``wait`` / ``recover``, never ``execute`` again. + +### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` + +Poll until the transfer finishes or needs you: stops at ``progress.next`` +in resume / complete / done / failed, or at any status in ``until``. + +A ``PollingTimeoutError`` is NOT a failure — the transfer is still in +flight; call ``wait`` again or ``recover`` later. ``on_update`` receives +each changed ``Progress``. A transient error (flaky RPC/HTTP transport) +is retried up to ``max_consecutive_errors`` times, calling ``on_error`` +on each tolerated retry; a non-transient error propagates immediately. + +### `recover(self, checkpoint)` + +Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + +Re-resolves the route from the live registry and reads chain state once; +``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, +``done`` or ``failed``. + +### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` + +Finish an interrupted source submission (``progress.next == "resume"``). + +Rebroadcasts the identical proved Aleo transaction (a duplicate response is +success) or, on EVM, re-scans history and only then authorizes the single +missing deposit/dispatch. Never repeats a confirmed step. + +### `complete(self, progress, *, secret_nonce: 'str', on_checkpoint=None, proving: 'str' = 'delegate')` + +Submit the private USDCx mint (``progress.next == "complete"``). + +Requires the same ``secret_nonce`` given to ``execute``; the SDK never +stored it. Submits exactly one ``private_mint`` and returns +``DESTINATION_CONFIRMING`` progress to ``wait`` on. + +### `pending(self) -> 'list'` + +The in-flight transfers of this profile — every checkpoint in the bound store, +reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one +unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` +with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` +on any entry to refresh it against live chain state. + +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. + + +## Tier 2 — the protocol modules (building your own flows) + +Every Aleo write returns an `AleoCall`: nothing touches the network until +`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not +broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local +proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana +writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`. +The lifecycle verbs above compose these; use them directly only when you +need a single leg. Confirm-gated writes and the never-resend rule above +apply here too — these are the same broadcasts, just one leg at a time. + +### `hyperlane.transfer_remote(self, asset: 'Any', recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, as_signer: 'bool' = False, gas_payment_microcredits: 'int | None' = None) -> 'AleoCall[DispatchReceipt]'` + +Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the +lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again. + +### `hyperlane.quote_gas_payment(self, asset: 'Any') -> 'GasQuote'` + +Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. + +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None) -> 'AleoCall[BurnReceipt]'` + +Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a +freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than +the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. + +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` + +Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). + +### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` + +One Circle request for *message_hash*; ``None`` while pending (404). + +### `shield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `freezelist.exclusion_proof(self, address: 'str', program: 'str') -> 'str'` + +``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty. + +### `eth.transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'EvmCall[DispatchReceipt]'` + +Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + +Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the +quoted token amount only when the allowance is short (USDT: a non-zero allowance is +reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes +send the fee only. Each hash is checkpointed before polling; a timeout returns a +pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. + +### `eth.deposit_usdc(self, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mint_mode: 'str | None' = None, secret_nonce: 'str' = '0scalar', plan: 'Plan | None' = None) -> 'EvmCall[DepositReceipt]'` + +Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + +``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private +record), or ``private`` (deposit addressed to the shielded wrapper program; you must later +run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, +which the SDK never stores). Approves exactly the amount only when the allowance is +short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` +carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to +``plan.mint_mode`` when a plan is given, else ``"public"``. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. ``secret_nonce`` is never +part of a plan, so a private deposit must still pass the same one it was quoted with. + +### `eth.quote_transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, route: 'Route | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'EvmHyperlaneQuote'` + +Quote an Ethereum → Aleo Hyperlane transfer without signing. + +Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so +``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): +``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 +allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + +``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and +amount, and is validated against the live registry. It is mutually exclusive with +``asset=``/``route=``/``sender=``. + +### `sol.transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'SolCall[DispatchReceipt]'` + +Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + +Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, +``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + +``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for +the connected wallet; its registry version and route id are re-checked against the live registry +when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a +``ValueError``. Without a plan, ``recipient`` is required. + +### `sol.quote_transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'SolanaHyperlaneQuote'` + +Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind +``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required +for the fee estimate. + +``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry +version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ +``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so +re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + +### Routes in the pinned registry + +| Route id | Protocol | Environment | Availability | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->ethereum/usdc` | xreserve | mainnet | active | +| `xreserve:sepolia/usdc->aleo-testnet/usdcx` | xreserve | testnet | active | +| `xreserve:aleo-testnet/usdcx->sepolia/usdc` | xreserve | testnet | active | +| `hyperlane:ethereum/eth->aleo/eth` | hyperlane | mainnet | active | +| `hyperlane:aleo/eth->ethereum/eth` | hyperlane | mainnet | active | +| `hyperlane:ethereum/wbtc->aleo/wbtc` | hyperlane | mainnet | active | +| `hyperlane:aleo/wbtc->ethereum/wbtc` | hyperlane | mainnet | active | +| `hyperlane:ethereum/usdt->aleo/usdt` | hyperlane | mainnet | active | +| `hyperlane:aleo/usdt->ethereum/usdt` | hyperlane | mainnet | active | +| `hyperlane:solana/sol->aleo/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/sol->solana/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/aleo->ethereum/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->solana/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:solana/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->base/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:base/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->hyperevm/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:hyperevm/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/usad->aleo/usad` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/usad->ethereum/usad` | hyperlane | mainnet | metadata-required | + +`metadata-required` routes are listed but refused by `quote`/`execute` +until their deployments are reviewed upstream. + diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py new file mode 100644 index 00000000..90ab664f --- /dev/null +++ b/bridge-sdk/codegen/gen_context.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# bridge-sdk/codegen/gen_context.py +"""Render AGENTS.md from the SDK's docstrings — the anti-drift context page. + +Tier 1 = the lifecycle verbs + the conversation pattern; Tier 2 = the protocol +modules and the registry's route table. Run with no args to rewrite both +copies of AGENTS.md; ``--check`` exits 1 when they are stale (CI); ``--stdout`` +prints instead of writing. +""" +from __future__ import annotations + +import argparse +import inspect +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "python")) + +from aleo_bridge import Bridge # noqa: E402 +from aleo_bridge.eth import EthModule # noqa: E402 +from aleo_bridge.freezelist import FreezeList # noqa: E402 +from aleo_bridge.hyperlane import HyperlaneModule # noqa: E402 +from aleo_bridge.registry import DEFAULT_REGISTRY # noqa: E402 +from aleo_bridge.sol import SolModule # noqa: E402 +from aleo_bridge.xreserve import XReserveModule # noqa: E402 + +OUTS = [_ROOT / "AGENTS.md", _ROOT / "python" / "aleo_bridge" / "AGENTS.md"] + +TIER1 = ["from_env", "from_profile", "status", "quote", "execute", "wait", "recover", "resume", + "complete", "pending"] +TIER2 = [ + ("hyperlane.transfer_remote", HyperlaneModule.transfer_remote), + ("hyperlane.quote_gas_payment", HyperlaneModule.quote_gas_payment), + ("xreserve.burn", XReserveModule.burn), + ("xreserve.private_mint", XReserveModule.private_mint), + ("xreserve.get_attestation", XReserveModule.get_attestation), + ("shield", Bridge.shield), + ("unshield", Bridge.unshield), + ("freezelist.exclusion_proof", FreezeList.exclusion_proof), + ("eth.transfer_remote", EthModule.transfer_remote), + ("eth.deposit_usdc", EthModule.deposit_usdc), + ("eth.quote_transfer_remote", EthModule.quote_transfer_remote), + ("sol.transfer_remote", SolModule.transfer_remote), + ("sol.quote_transfer_remote", SolModule.quote_transfer_remote), +] + +QUICKSTART = """\ +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +```""" + +CONVERSATION_PATTERN = """\ +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. +""" + + +def _entry(name: str, fn: object) -> str: + try: + sig = str(inspect.signature(fn)) # type: ignore[arg-type] + except (TypeError, ValueError): + sig = "(...)" + doc = inspect.getdoc(fn) or "" + return f"### `{name}{sig}`\n\n{doc.strip()}\n" + + +def _route_table() -> list[str]: + rows = ["| Route id | Protocol | Environment | Availability |", "| --- | --- | --- | --- |"] + for route in DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None): + rows.append(f"| `{route.id}` | {route.protocol} | {route.environment} | {route.availability} |") + return rows + + +def render() -> str: + parts = [ + "# aleo-bridge — agent guide", + "", + "> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not", + "> edit by hand; edit the docstrings and regenerate.", + "", + "Typed Python client that moves assets between Aleo, Ethereum and Solana", + "over the reviewed Hyperlane warp routes and Circle xReserve deployments", + "(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`).", + "MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as", + "tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas.", + f"Registry version `{DEFAULT_REGISTRY.version}`.", + "", + "## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked)", + "", + QUICKSTART, + "", + ] + parts += [_entry(n, getattr(Bridge, n)) for n in TIER1] + parts += [ + CONVERSATION_PATTERN, + "", + "## Tier 2 — the protocol modules (building your own flows)", + "", + "Every Aleo write returns an `AleoCall`: nothing touches the network until", + "`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not", + "broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local", + "proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana", + "writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`.", + "The lifecycle verbs above compose these; use them directly only when you", + "need a single leg. Confirm-gated writes and the never-resend rule above", + "apply here too — these are the same broadcasts, just one leg at a time.", + "", + ] + parts += [_entry(name, fn) for name, fn in TIER2] + parts += ["### Routes in the pinned registry", ""] + parts += _route_table() + parts += ["", "`metadata-required` routes are listed but refused by `quote`/`execute`", + "until their deployments are reviewed upstream.", ""] + return "\n".join(parts) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", help="exit 1 when AGENTS.md is stale (CI gate)") + ap.add_argument("--stdout", action="store_true") + args = ap.parse_args() + page = render() + if args.stdout: + print(page, end="") + return 0 + if args.check: + for out in OUTS: + current = out.read_text() if out.exists() else "" + if current != page: + print(f"{out} is stale — run: python codegen/gen_context.py", file=sys.stderr) + return 1 + return 0 + for out in OUTS: + out.write_text(page) + print(f"wrote {out} ({len(page)} chars)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md new file mode 100644 index 00000000..93291cb0 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -0,0 +1,331 @@ +# aleo-bridge — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client that moves assets between Aleo, Ethereum and Solana +over the reviewed Hyperlane warp routes and Circle xReserve deployments +(`pip install aleo-bridge-sdk[evm,solana]`, imports as `aleo_bridge`). +MCP alternative: `python -m aleo_bridge.mcp` exposes the same lifecycle as +tools; `aleo_bridge.agent.bridge_tools()` gives Claude-shape tool schemas. +Registry version `2026-08-31.solana-deposits.1`. + +## Tier 1 — the lifecycle (quote → execute → wait, then resume / complete as asked) + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # BRIDGE_PRIVATE_KEY (+ EVM/Solana keys) from the environment +print(bridge.status()) # addresses, balances, pending transfers +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.fees, quote.amount_out) # show these to the user BEFORE executing +progress = bridge.execute(quote.plan) # source step; checkpoints saved to the bound store +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=nonce)) +assert progress.next == "done", progress.error +``` + +### `from_env(**overrides: 'Any') -> "'Bridge'"` + +Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints. + +### `from_profile(home: 'Any' = None, *, network: 'str | None' = None, endpoint: 'str | None' = None, ethereum: 'Any' = None, solana: 'Any' = None) -> "'Bridge'"` + +The client for the local profile (spec §3.4), created on first use. *network*/*endpoint* apply only when +creating. Side-chain connections come from the arguments or the same env variables as ``from_env``. + +### `status(self) -> 'BridgeStatus'` + +Read-only re-orientation: addresses and public balances of every registry asset per configured chain. +Plan 4 fills ``pending`` from the checkpoint store. + +### `quote(self, source, destination, *, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, protocol: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar')` + +Price a transfer and get the plan that ``execute`` takes. Nothing is signed. + +``source`` / ``destination`` are ``"chain/key"`` strings or ``(chain, key)`` +tuples (``"ethereum/usdc"``, ``"aleo/usdcx"``); give exactly one of +``amount`` (human units, str) or ``amount_atomic`` (int). ``recipient`` is +the destination-chain address. ``mint_mode`` (xReserve into Aleo only): +``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` +— you finish it yourself with ``complete`` and must keep ``secret_nonce``. +Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / +solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with +``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the +user fees + amount before ``execute``. + +### `execute(self, plan, *, on_checkpoint=None, proving: 'str' = 'delegate', mode: 'str | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, gas_payment_microcredits: 'int | None' = None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0)` + +Commit funds on the source chain for ``quote.plan``; returns ``Progress``. + +Runs approval(s) → deposit / dispatch / burn, emitting a ``Checkpoint`` to +``on_checkpoint`` (and the bound store) at every boundary — including +AFTER proving and BEFORE broadcast for Aleo legs, so a crash there is +resumable without proving twice. ``proving`` is ``"delegate"`` (DPS) or +``"local"``; ``mode`` is ``"caller"|"signer"`` (Aleo Hyperlane) or +``"private"|"public"|"public-as-signer"`` (Aleo xReserve burn, default +private; ``record``/``merkle_proof`` optional — the SDK selects a record +and computes the exclusion proof). The Hyperlane hook payment is +re-quoted right before proving unless ``gas_payment_microcredits`` is +pinned. Irreversible once the source step is broadcast: afterwards use +``wait`` / ``recover``, never ``execute`` again. + +### `wait(self, progress, *, until=None, poll_seconds: 'float' = 15.0, timeout_seconds: 'float' = 1200.0, on_update=None, on_error=None, max_consecutive_errors: 'int' = 5)` + +Poll until the transfer finishes or needs you: stops at ``progress.next`` +in resume / complete / done / failed, or at any status in ``until``. + +A ``PollingTimeoutError`` is NOT a failure — the transfer is still in +flight; call ``wait`` again or ``recover`` later. ``on_update`` receives +each changed ``Progress``. A transient error (flaky RPC/HTTP transport) +is retried up to ``max_consecutive_errors`` times, calling ``on_error`` +on each tolerated retry; a non-transient error propagates immediately. + +### `recover(self, checkpoint)` + +Rebuild ``Progress`` from a saved checkpoint (``Checkpoint``, dict or JSON) — reads only. + +Re-resolves the route from the live registry and reads chain state once; +``progress.next`` then says what to do: ``wait``, ``resume``, ``complete``, +``done`` or ``failed``. + +### `resume(self, progress, *, on_checkpoint=None, secret_nonce: 'str | None' = None, poll_seconds: 'float' = 1.0, timeout_seconds: 'float' = 120.0, proving: 'str' = 'delegate')` + +Finish an interrupted source submission (``progress.next == "resume"``). + +Rebroadcasts the identical proved Aleo transaction (a duplicate response is +success) or, on EVM, re-scans history and only then authorizes the single +missing deposit/dispatch. Never repeats a confirmed step. + +### `complete(self, progress, *, secret_nonce: 'str', on_checkpoint=None, proving: 'str' = 'delegate')` + +Submit the private USDCx mint (``progress.next == "complete"``). + +Requires the same ``secret_nonce`` given to ``execute``; the SDK never +stored it. Submits exactly one ``private_mint`` and returns +``DESTINATION_CONFIRMING`` progress to ``wait`` on. + +### `pending(self) -> 'list'` + +The in-flight transfers of this profile — every checkpoint in the bound store, +reconstructed offline (:func:`lifecycle.progress_from_checkpoint`): no network read, so one +unreachable chain can never hide the others. A malformed checkpoint yields a ``Progress`` +with ``next == "failed"`` and ``error`` set instead of raising; call ``wait()``/``recover()`` +on any entry to refresh it against live chain state. + +## Serving a chatting user (the conversation pattern) + +### Keys and identity + +1. **NEVER ask the user to paste a private key into the conversation.** Keys + come from the environment only: `BRIDGE_PRIVATE_KEY` (Aleo), + `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL`, `SOLANA_PRIVATE_KEY` (+ optional + `SOLANA_RPC_URL`), set in the user's own shell before the process starts. + `Bridge.from_profile()` creates an Aleo key on first use and never writes + EVM/Solana keys to disk. +2. `status()` first in any session: which chains are configured, balances of + every bridge asset, and the pending transfers in the checkpoint store. A + pending transfer is finished with `recover` → `wait`/`resume`/`complete`, + never by starting a new one. + +### Quote first, always + +3. **Always `quote` before `execute`** and show the user the route, the fees + and `amount_out` in human units with symbols ("2 USDC → 2 USDCx; Hyperlane + hook payment 8.17 ALEO"), never raw atomic units. Minimums: xReserve + needs at least 2 USDC in and strictly more than the 2 USDCx withdrawal fee + out; Hyperlane moves one atomic unit but network fees and the relayer + payment cost more than that — say so. +4. Only `execute` after the user confirms. Through the agent tools every + write requires `confirm=true`; without it the tool returns the quote and + moves nothing. A live mainnet execution additionally needs the user's + own `BRIDGE_LIVE_MAINNET_EXECUTE` acknowledgement — never set it yourself; + without it, treat any mainnet run as a rehearsal. + +### The source step is irreversible + +5. Once the deposit / dispatch / burn is broadcast the funds are committed. + A timeout, an RPC error or a crash after that point is an UNKNOWN outcome, + not a failure: recover from the last checkpoint (`recover(checkpoint)` or + `pending()`) — never run `execute` again for the same transfer. This is + the funds-safety rule above all others: never resend after an ambiguous + broadcast. + +### What `progress.next` means for the user + +| `progress.next` | Status | Tell the user | Do | +| --- | --- | --- | --- | +| `wait` | source confirming, attestation pending, delivery pending | "In flight; I'll keep checking." | `wait(progress)` (or re-check later from the checkpoint) | +| `resume` | `SOURCE_SUBMISSION_PENDING` | "An approval confirmed / a proof was built but the transfer itself was not submitted; I can submit it now." | confirm, then `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | "Circle attested your deposit; your private mint needs your signature (and the secret nonce)." | confirm, then `complete(progress, secret_nonce=...)` | +| `done` | `COMPLETED` | "Delivered." Report source and destination transaction ids. | nothing | +| `failed` | `FAILED` / `EXPIRED` | Relay `progress.error`; the source step did not commit funds or was rejected. | nothing — a new transfer needs a new quote | + +`wait` raising `PollingTimeoutError` is NOT a failure — say the transfer is +still in flight and check again later. + +### Private mints and the secret nonce + +6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on + Ethereum. The same `secret_nonce` is required by `complete`; the SDK + **never stores** it and checkpoints exclude it (and every other secret). + Tell the user to keep it (the default `0scalar` needs no storage but adds + no entropy). Only the recipient's Aleo key can complete a private mint — + make sure the recipient IS the configured Aleo address before depositing. +7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private + record first. Hyperlane delivers into public balances; `shield` afterwards + if the user wants privacy. Private xReserve burns spend records directly. + +### While acting + +8. Writes are slow (proving + confirmation ≈ a minute or two on Aleo; Circle + attestation and Hyperlane relay take minutes). Never re-submit because a + call seems slow — `status()` / `recover` first. +9. Confirm, act, report ids. Errors name their own fix — read the exception + message and do what it says. + + +## Tier 2 — the protocol modules (building your own flows) + +Every Aleo write returns an `AleoCall`: nothing touches the network until +`.simulate()` (free), `.prove()` / `.delegate_prepared()` (proved, not +broadcast — checkpoint it), `.submit_prepared()`, `.transact()` (local +proving + broadcast) or `.delegate()` (DPS + broadcast). EVM and Solana +writes return `EvmCall` / `SolCall` with `.build()` (unsigned) and `.send()`. +The lifecycle verbs above compose these; use them directly only when you +need a single leg. Confirm-gated writes and the never-resend rule above +apply here too — these are the same broadcasts, just one leg at a time. + +### `hyperlane.transfer_remote(self, asset: 'Any', recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, as_signer: 'bool' = False, gas_payment_microcredits: 'int | None' = None) -> 'AleoCall[DispatchReceipt]'` + +Withdraw an Aleo warp asset to Ethereum/Solana. Quotes the IGP payment now unless pinned; the +lifecycle layer (plan 4) re-quotes at the last responsible moment by calling this again. + +### `hyperlane.quote_gas_payment(self, asset: 'Any') -> 'GasQuote'` + +Live relayer payment for the route (the exact u64 the hook asserts); quote right before proving. + +### `xreserve.burn(self, recipient: 'str', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mode: 'str' = 'private', record: 'str | None' = None, merkle_proof: 'str | None' = None) -> 'AleoCall[BurnReceipt]'` + +Burn USDCx for USDC on Ethereum. ``private`` (default) spends a Token record via the wrapper and needs a +freeze-list exclusion proof — both are resolved from chain state when not supplied. Minimum: more than +the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. + +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` + +Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). + +### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` + +One Circle request for *message_hash*; ``None`` while pending (404). + +### `shield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `unshield(self, asset: 'Any', *, amount: 'Any' = None, amount_atomic: 'int | None' = None, record: 'str | None' = None, merkle_proof: 'str | None' = None, recipient: 'str | None' = None) -> 'AleoCall[PrivacyReceipt]'` + + + +### `freezelist.exclusion_proof(self, address: 'str', program: 'str') -> 'str'` + +``[MerkleProof; 2]`` proving *address* is not frozen on *program*; veil's empty pair when the list is empty. + +### `eth.transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'EvmCall[DispatchReceipt]'` + +Send ETH, WBTC or USDT to Aleo through its Hyperlane Warp Route. + +Re-quotes ``quoteTransferRemote`` at send time. Collateral routes approve exactly the +quoted token amount only when the allowance is short (USDT: a non-zero allowance is +reset to 0 first). Native ETH sends amount + fee as ``msg.value``; collateral routes +send the fee only. Each hash is checkpointed before polling; a timeout returns a +pending ``DispatchReceipt``. The message id comes from the Mailbox ``DispatchId`` log. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. Mutually exclusive with ``asset=``. + +### `eth.deposit_usdc(self, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, mint_mode: 'str | None' = None, secret_nonce: 'str' = '0scalar', plan: 'Plan | None' = None) -> 'EvmCall[DepositReceipt]'` + +Deposit USDC into Circle xReserve for USDCx on Aleo (minimum 2 USDC; irreversible once confirmed). + +``mint_mode``: ``public`` (public USDCx balance), ``record`` (protocol-minted private +record), or ``private`` (deposit addressed to the shielded wrapper program; you must later +run ``bridge.xreserve.private_mint`` / plan 4's ``complete`` with the same ``secret_nonce``, +which the SDK never stores). Approves exactly the amount only when the allowance is +short, then ``depositToRemote`` with no ``msg.value``. The confirmed ``DepositReceipt`` +carries Circle's message hash (receipt id) and the deposit nonce. ``mint_mode`` defaults to +``plan.mint_mode`` when a plan is given, else ``"public"``. + +``plan=`` executes a plan prepared earlier (typically ``quote.plan``): the route is +re-resolved by id against the live registry, the sender must be the connected account, and +the plan must equal what this call would have prepared itself. ``secret_nonce`` is never +part of a plan, so a private deposit must still pass the same one it was quoted with. + +### `eth.quote_transfer_remote(self, asset: 'Any' = None, recipient: 'str | None' = None, *, amount: 'Any' = None, amount_atomic: 'int | None' = None, route: 'Route | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'EvmHyperlaneQuote'` + +Quote an Ethereum → Aleo Hyperlane transfer without signing. + +Native routes (ETH): ``msg.value`` carries the asset and the relayer fee, so +``native_fee_atomic = native_value_atomic - amount``. Collateral routes (WBTC, USDT): +``msg.value`` is fee only and ``approval_required`` reflects the router's ERC-20 +allowance for ``sender`` (or the connection's account); it is ``None`` when no account is known. + +``plan=`` re-quotes a plan prepared earlier: it supplies the route, sender, recipient and +amount, and is validated against the live registry. It is mutually exclusive with +``asset=``/``route=``/``sender=``. + +### `sol.transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, plan: 'Plan | None' = None) -> 'SolCall[DispatchReceipt]'` + +Send native SOL to an Aleo address over the Hyperlane warp route (spec §6). + +Returns a :class:`SolCall`: ``build()`` previews the partially signed transaction, +``send()`` moves funds (amount + IGP payment + network fee + rent leave the wallet). + +``plan`` (from ``Bridge.execute``) supplies recipient and amount and must have been prepared for +the connected wallet; its registry version and route id are re-checked against the live registry +when the call runs. An ``amount``/``amount_atomic`` that disagrees with the plan is a +``ValueError``. Without a plan, ``recipient`` is required. + +### `sol.quote_transfer_remote(self, recipient: 'str | None' = None, *, amount: 'str | None' = None, amount_atomic: 'int | None' = None, sender: 'str | None' = None, plan: 'Plan | None' = None) -> 'SolanaHyperlaneQuote'` + +Lamports required for a SOL → Aleo transfer: amount + IGP payment + network fee + rent (spec §5 kind +``solana-hyperlane``). Reads Solana; never signs. ``sender`` defaults to the connected wallet and is required +for the fee estimate. + +``plan`` (from ``Bridge.quote``) supplies recipient, amount and sender, and must match the live registry +version and route; like ``EthModule`` it is mutually exclusive with ``sender=``, and an ``amount``/ +``amount_atomic`` that disagrees with the plan is a ``ValueError`` (an identical one is tolerated, so +re-stating the plan's own amount is harmless). Without a plan, ``recipient`` is required. + +### Routes in the pinned registry + +| Route id | Protocol | Environment | Availability | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | xreserve | mainnet | active | +| `xreserve:aleo/usdcx->ethereum/usdc` | xreserve | mainnet | active | +| `xreserve:sepolia/usdc->aleo-testnet/usdcx` | xreserve | testnet | active | +| `xreserve:aleo-testnet/usdcx->sepolia/usdc` | xreserve | testnet | active | +| `hyperlane:ethereum/eth->aleo/eth` | hyperlane | mainnet | active | +| `hyperlane:aleo/eth->ethereum/eth` | hyperlane | mainnet | active | +| `hyperlane:ethereum/wbtc->aleo/wbtc` | hyperlane | mainnet | active | +| `hyperlane:aleo/wbtc->ethereum/wbtc` | hyperlane | mainnet | active | +| `hyperlane:ethereum/usdt->aleo/usdt` | hyperlane | mainnet | active | +| `hyperlane:aleo/usdt->ethereum/usdt` | hyperlane | mainnet | active | +| `hyperlane:solana/sol->aleo/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/sol->solana/sol` | hyperlane | mainnet | active | +| `hyperlane:aleo/aleo->ethereum/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->solana/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:solana/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->base/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:base/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/aleo->hyperevm/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:hyperevm/aleo->aleo/aleo` | hyperlane | mainnet | metadata-required | +| `hyperlane:ethereum/usad->aleo/usad` | hyperlane | mainnet | metadata-required | +| `hyperlane:aleo/usad->ethereum/usad` | hyperlane | mainnet | metadata-required | + +`metadata-required` routes are listed but refused by `quote`/`execute` +until their deployments are reviewed upstream. + diff --git a/bridge-sdk/python/aleo_bridge/__main__.py b/bridge-sdk/python/aleo_bridge/__main__.py index e294d300..9b845b03 100644 --- a/bridge-sdk/python/aleo_bridge/__main__.py +++ b/bridge-sdk/python/aleo_bridge/__main__.py @@ -1,4 +1,5 @@ -"""``python -m aleo_bridge [status|routes|assets]`` — status needs BRIDGE_PRIVATE_KEY (read-only).""" +"""``python -m aleo_bridge`` prints the agent guide; ``[status|routes|assets]`` run those checks +(``status`` needs BRIDGE_PRIVATE_KEY, read-only).""" from __future__ import annotations import dataclasses @@ -12,7 +13,11 @@ def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - command = args[0] if args else "status" + if not args: + from . import agent_guide + print(agent_guide(), end="") + return 0 + command = args[0] if command == "routes": print(json.dumps([r.id for r in DEFAULT_REGISTRY.routes(include_unavailable=True)], indent=1)) return 0 diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index a98d8c90..5a073a0e 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -220,3 +220,12 @@ def test_cli_lists_routes_and_assets(capsys): assert cli.main(["assets"]) == 0 assert len(json.loads(capsys.readouterr().out)) == 19 assert cli.main(["bogus"]) == 2 + + +def test_cli_prints_agent_guide_by_default(capsys): + import aleo_bridge + + assert cli.main([]) == 0 + out = capsys.readouterr().out + assert out == aleo_bridge.agent_guide() + assert "# aleo-bridge — agent guide" in out diff --git a/bridge-sdk/tests/test_gen_context.py b/bridge-sdk/tests/test_gen_context.py new file mode 100644 index 00000000..033fef63 --- /dev/null +++ b/bridge-sdk/tests/test_gen_context.py @@ -0,0 +1,49 @@ +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GEN = ROOT / "codegen" / "gen_context.py" + + +def _render() -> str: + out = subprocess.run([sys.executable, str(GEN), "--stdout"], capture_output=True, text=True, cwd=ROOT) + assert out.returncode == 0, out.stderr + return out.stdout + + +def test_tier1_lifecycle_and_conversation_pattern(): + page = _render() + for verb in ("from_env", "from_profile", "status", "quote", "execute", "wait", "recover", "resume", + "complete", "pending"): + assert f"### `{verb}(" in page, verb + assert "## Serving a chatting user" in page + assert "NEVER ask the user to paste a private key" in page + assert "quote first" in page.lower() and "human units" in page.lower() + for nxt in ("`wait`", "`resume`", "`complete`", "`done`", "`failed`"): + assert nxt in page # progress.next table + assert "irreversible" in page.lower() + assert "secret_nonce" in page and "never stores" in page.lower() + + +def test_tier2_modules_and_registry_table(): + page = _render() + for method in ("hyperlane.transfer_remote", "hyperlane.quote_gas_payment", "xreserve.burn", + "xreserve.private_mint", "xreserve.get_attestation", "shield", "unshield", + "freezelist.exclusion_proof", "eth.transfer_remote", "eth.deposit_usdc", + "eth.quote_transfer_remote", "sol.transfer_remote", "sol.quote_transfer_remote"): + assert f"### `{method}(" in page, method + from aleo_bridge.registry import DEFAULT_REGISTRY + for route in DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None): + assert f"`{route.id}`" in page, route.id + assert "| metadata-required |" in page and "| active |" in page + + +def test_committed_pages_are_current(): + check = subprocess.run([sys.executable, str(GEN), "--check"], capture_output=True, text=True, cwd=ROOT) + assert check.returncode == 0, check.stderr or "AGENTS.md stale — run codegen/gen_context.py" + assert (ROOT / "AGENTS.md").read_text() == (ROOT / "python" / "aleo_bridge" / "AGENTS.md").read_text() + + +def test_page_stays_compact(): + assert len(_render()) < 40_000 From b87883ae59fe9c211dd349ade2fe96b8792576ed Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 14:52:31 -0400 Subject: [PATCH 87/94] test(bridge-sdk): live harness config/helpers ported from veil, with 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 .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. --- bridge-sdk/tests/live/config.py | 141 +++++++++ bridge-sdk/tests/live/helpers.py | 393 +++++++++++++++++++++++++ bridge-sdk/tests/test_live_helpers.py | 407 ++++++++++++++++++++++++++ 3 files changed, 941 insertions(+) create mode 100644 bridge-sdk/tests/live/config.py create mode 100644 bridge-sdk/tests/live/helpers.py create mode 100644 bridge-sdk/tests/test_live_helpers.py diff --git a/bridge-sdk/tests/live/config.py b/bridge-sdk/tests/live/config.py new file mode 100644 index 00000000..e0572fe5 --- /dev/null +++ b/bridge-sdk/tests/live/config.py @@ -0,0 +1,141 @@ +"""Gates and paths for the funded live cases — a port of veil's `test/integration/live/config.ts`. + +Every function here only READS the environment. Nothing in this repository sets, defaults or +repairs a gate variable: the acknowledgement strings exist so that a human types them into their +own shell for one command, and an agent that exports them has defeated the only safeguard between +a test run and somebody's money. Values are never logged — errors name the VARIABLE, never what +it contained. + +Gates (veil config.ts:17-36): + +* ``BRIDGE_LIVE_FUNDS=1`` **and** ``BRIDGE_LIVE_STATE_DIR=`` — live-funds + tests exist at all. +* ``BRIDGE_LIVE_MAINNET_ACK=I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS`` **and** + ``BRIDGE_LIVE_MAINNET_CASES=`` — the named mainnet case may run. +* ``BRIDGE_LIVE_MAINNET_EXECUTE=I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS`` — the wallet may + actually submit. Without it a case runs to the quote and returns (veil's + ``if (!mainnetExecutionEnabled()) return``). +""" +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Mapping + +#: The five mainnet cases veil ships, in the order the funding table lists them. +CASE_NAMES = ("evm-hyperlane", "evm-xreserve", "aleo-hyperlane", "aleo-xreserve", "solana-hyperlane") + +FUNDS_VAR = "BRIDGE_LIVE_FUNDS" +STATE_DIR_VAR = "BRIDGE_LIVE_STATE_DIR" +MAINNET_ACK_VAR = "BRIDGE_LIVE_MAINNET_ACK" +MAINNET_ACK = "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS" +MAINNET_CASES_VAR = "BRIDGE_LIVE_MAINNET_CASES" +MAINNET_EXECUTE_VAR = "BRIDGE_LIVE_MAINNET_EXECUTE" +MAINNET_EXECUTE_ACK = "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS" + +ENVIRONMENTS = ("mainnet", "testnet") + +#: Recipient overrides per destination-chain family; the default is our own address on that chain. +RECIPIENT_VARS = { + "aleo": "BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT", + "evm": "BRIDGE_LIVE_EVM_RECIPIENT", + "solana": "BRIDGE_LIVE_SOLANA_RECIPIENT", +} + +_EVM_KEY_RE = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE) + + +class LiveConfigError(Exception): + """A live case was explicitly enabled but is not configured (never carries a secret value).""" + + +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def value(name: str, env: Mapping[str, str] | None = None) -> str | None: + """The trimmed value of *name*, or None when unset or blank. Never logs.""" + raw = _env(env).get(name) + trimmed = raw.strip() if isinstance(raw, str) else None + return trimmed or None + + +def required(name: str, env: Mapping[str, str] | None = None) -> str: + """veil ``required()``: the value of *name*, or a clear error naming only the variable.""" + found = value(name, env) + if not found: + raise LiveConfigError(f"Missing {name}; the live bridge case was explicitly enabled but is not configured") + return found + + +def required_evm_private_key(name: str, env: Mapping[str, str] | None = None) -> str: + """veil ``requiredEvmPrivateKey()``: one 32-byte key normalised to ``0x…``; the value never appears.""" + raw = required(name, env) + body = raw[2:] if raw[:2].lower() == "0x" else raw + if not _EVM_KEY_RE.match(body): + raise LiveConfigError(f"{name} must contain exactly 32 hexadecimal bytes") + return f"0x{body.lower()}" + + +def live_funds_enabled(env: Mapping[str, str] | None = None) -> bool: + """veil ``liveFundsEnabled()``: funded cases exist at all (flag exactly ``"1"`` + a state dir).""" + source = _env(env) + return source.get(FUNDS_VAR) == "1" and bool(value(STATE_DIR_VAR, env)) + + +def mainnet_case_enabled(name: str, env: Mapping[str, str] | None = None) -> bool: + """veil ``mainnetCaseEnabled()``: funds gate + the exact acknowledgement + *name* in the case list.""" + if not live_funds_enabled(env): + return False + if _env(env).get(MAINNET_ACK_VAR) != MAINNET_ACK: + return False + listed = {entry.strip() for entry in (value(MAINNET_CASES_VAR, env) or "").split(",")} + return name in (listed - {""}) + + +def mainnet_execution_enabled(env: Mapping[str, str] | None = None) -> bool: + """veil ``mainnetExecutionEnabled()``: the wallet may submit. Read here, typed by a human elsewhere.""" + return _env(env).get(MAINNET_EXECUTE_VAR) == MAINNET_EXECUTE_ACK + + +def one_atomic_unit(decimals: int) -> str: + """veil ``oneAtomicUnit()``: the smallest positive display amount of an asset ("0.000001" at 6).""" + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise LiveConfigError(f"Invalid asset decimals: {decimals!r}") + return "1" if decimals == 0 else f"0.{'0' * (decimals - 1)}1" + + +def state_dir(env: Mapping[str, str] | None = None) -> Path: + """``BRIDGE_LIVE_STATE_DIR`` as a path — the operator's own directory, outside this repository.""" + return Path(required(STATE_DIR_VAR, env)).expanduser() + + +def live_state_path(environment: str, name: str, env: Mapping[str, str] | None = None) -> Path: + """veil ``liveStatePath()``: ``//.json``.""" + if environment not in ENVIRONMENTS: + raise LiveConfigError(f"environment must be one of {ENVIRONMENTS}, got {environment!r}") + return state_dir(env) / environment / f"{name}.json" + + +def case_route_override(case: str, env: Mapping[str, str] | None = None) -> str | None: + """``BRIDGE_LIVE__ROUTE_ID`` (veil's per-case route override), or None.""" + return value(f"BRIDGE_LIVE_{case.replace('-', '_').upper()}_ROUTE_ID", env) + + +def recipient_override(family: str, env: Mapping[str, str] | None = None) -> str | None: + """The operator's recipient override for a destination-chain *family*, or None (use our own address).""" + try: + name = RECIPIENT_VARS[family] + except KeyError: + raise LiveConfigError(f"No recipient override variable for chain family {family!r}") from None + return value(name, env) + + +__all__ = [ + "CASE_NAMES", "ENVIRONMENTS", "FUNDS_VAR", "LiveConfigError", "MAINNET_ACK", "MAINNET_ACK_VAR", + "MAINNET_CASES_VAR", "MAINNET_EXECUTE_ACK", "MAINNET_EXECUTE_VAR", "RECIPIENT_VARS", "STATE_DIR_VAR", + "case_route_override", "live_funds_enabled", "live_state_path", "mainnet_case_enabled", + "mainnet_execution_enabled", "one_atomic_unit", "recipient_override", "required", + "required_evm_private_key", "state_dir", "value", +] diff --git a/bridge-sdk/tests/live/helpers.py b/bridge-sdk/tests/live/helpers.py new file mode 100644 index 00000000..7628a3b6 --- /dev/null +++ b/bridge-sdk/tests/live/helpers.py @@ -0,0 +1,393 @@ +"""Persistence, polling and timing for the funded live cases — a port of veil's +`test/integration/live/helpers.ts` (plus the secret-nonce file veil does not have). + +Nothing here signs or submits anything. What it does own is the memory of a run: the state file +that lets a case resume across processes, the private-mint secret that must survive a crash but +must never reach a log, a checkpoint or the state JSON, and the read-only lookups that answer +"did it arrive?". + +State files are veil-shaped on disk (camelCase keys, one JSON object per case) and fail CLOSED: +a corrupt or wrong-route file raises rather than quietly starting a fresh transfer over funds that +may already be in flight. +""" +from __future__ import annotations + +import json +import os +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable + +from aleo_bridge._base58 import b58decode +from aleo_bridge.encoding import validate_scalar +from aleo_bridge.lifecycle import aleo_transaction_status + +#: veil helpers.ts:85 — the Hyperlane explorer's public GraphQL endpoint (read-only). +HYPERLANE_EXPLORER_URL = "https://explorer4.hasura.app/v1/graphql" +HYPERLANE_QUERY = """query ByOrigin($hash: bytea!) { + message_view(where: {origin_tx_hash: {_eq: $hash}}, limit: 1) { + msg_id is_delivered destination_tx_hash + } +}""" + +DEFAULT_TIMEOUT_SECONDS = 1200.0 # veil helpers.ts:70 — 20 minutes +DEFAULT_POLL_SECONDS = 15.0 + +_SECRET_SUFFIX = ".secret" +_STATE_FIELDS = { + "route_id": "routeId", + "source_tx_id": "sourceTxId", + "message_id": "messageId", + "destination_tx_id": "destinationTxId", + "destination_balance_before": "destinationBalanceBefore", + "completed": "completed", + "checkpoint": "checkpoint", + "secret_nonce_present": "secretNoncePresent", +} +_STRING_FIELDS = ("source_tx_id", "message_id", "destination_tx_id", "destination_balance_before") + + +class LiveStateError(Exception): + """A state file could not be trusted: corrupt, malformed, or bound to another route.""" + + +class LiveTimeoutError(Exception): + """A live verification ran out of time; the transfer is still in flight (never a failure verdict).""" + + +class ExplorerError(Exception): + """The Hyperlane explorer answered with a GraphQL error rather than data.""" + + +class LiveCaseError(Exception): + """A live case cannot continue (a rejected source transaction, an expired blockhash, …).""" + + +class Underfunded(Exception): + """A wallet cannot cover amount + fees. Callers turn this into a skip, printing the shortfall.""" + + def __init__(self, *, asset_id: str, needed: int, have: int, what: str = "balance") -> None: + self.asset_id, self.needed, self.have, self.what = asset_id, needed, have, what + self.shortfall = max(needed - have, 0) + super().__init__(f"Insufficient {asset_id} {what}: need {needed} atomic units, have {have} " + f"(short {self.shortfall})") + + +# ── state files ─────────────────────────────────────────────────────────────── + +@dataclass +class LiveState: + """veil ``LiveState`` + ``secretNoncePresent``. + + The private-mint nonce itself is NOT a field: it lives in the sibling ``.secret`` file + (mode 0600) and only its presence is recorded here, so a state file can be pasted into a bug + report without leaking the commitment secret. + """ + + route_id: str + source_tx_id: str | None = None + message_id: str | None = None + destination_tx_id: str | None = None + destination_balance_before: str | None = None + completed: bool = False + checkpoint: dict[str, Any] | None = None + secret_nonce_present: bool = False + + def to_dict(self) -> dict[str, Any]: + """veil's on-disk shape: camelCase keys, unset optionals omitted.""" + raw = asdict(self) + out: dict[str, Any] = {} + for attribute, key in _STATE_FIELDS.items(): + value = raw[attribute] + if value is None or value is False: + continue + out[key] = value + out["routeId"] = self.route_id + return out + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "LiveState": + return cls(**{attribute: data.get(key) for attribute, key in _STATE_FIELDS.items() + if data.get(key) is not None}) + + +def _validate(state: LiveState, route_id: str, path: Path) -> LiveState: + if state.route_id != route_id: + raise LiveStateError(f"Live state route {state.route_id!r} does not match {route_id!r}: {path}") + for attribute in _STRING_FIELDS: + value = getattr(state, attribute) + if value is not None and not isinstance(value, str): + raise LiveStateError(f"Live state {_STATE_FIELDS[attribute]} is invalid: {path}") + if not isinstance(state.completed, bool): + raise LiveStateError(f"Live state completed flag is invalid: {path}") + if not isinstance(state.secret_nonce_present, bool): + raise LiveStateError(f"Live state secretNoncePresent flag is invalid: {path}") + if state.checkpoint is not None and not isinstance(state.checkpoint, dict): + raise LiveStateError(f"Live state checkpoint is invalid: {path}") + return state + + +def load_live_state(path: Path | str, route_id: str) -> LiveState: + """veil ``loadLiveState()``: the saved state for *route_id*, or an empty one when absent. + + Fails closed on corrupt JSON, a non-object payload, a wrong route or a malformed field — a + state file we cannot read is never treated as "no transfer in flight". + """ + target = Path(path) + try: + raw = target.read_text(encoding="utf-8") + except FileNotFoundError: + return LiveState(route_id=route_id) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise LiveStateError(f"Live state is not valid JSON: {target} ({exc})") from exc + if not isinstance(parsed, dict): + raise LiveStateError(f"Live state is not an object: {target}") + if not isinstance(parsed.get("routeId"), str): + raise LiveStateError(f"Live state routeId is invalid: {target}") + return _validate(LiveState.from_dict(parsed), route_id, target) + + +def save_live_state(path: Path | str, state: LiveState) -> Path: + """veil ``saveLiveState()``: atomic (temp file + ``os.replace``), mode 0600, parents 0700.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = target.with_name(f".{target.name}.tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(state.to_dict(), indent=2, sort_keys=True)) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, target) + except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + return target + + +# ── the private-mint secret nonce (beside the state, never inside it) ───────── + +def generate_secret_nonce() -> str: + """A fresh Aleo scalar literal for one private mint (248 random bits, below the scalar modulus).""" + import secrets + + return validate_scalar(f"{secrets.randbits(248)}scalar") + + +def secret_path(state_path: Path | str) -> Path: + """``.secret`` — the sibling file that holds the nonce for that case.""" + target = Path(state_path) + return target.with_name(target.name + _SECRET_SUFFIX) + + +def save_secret_nonce(state_path: Path | str, nonce: str) -> Path: + """Write *nonce* exclusively (``O_CREAT|O_EXCL``, 0600). An existing file raises rather than being + overwritten: the nonce of a deposit already on chain is the only way to finish that mint.""" + validate_scalar(nonce) + target = secret_path(state_path) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(f"{nonce}\n") + return target + + +def load_secret_nonce(state_path: Path | str) -> str | None: + """The kept nonce for this case, or None when there is no secret file.""" + try: + text = secret_path(state_path).read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + return validate_scalar(text) + + +def ensure_secret_nonce(state_path: Path | str) -> str: + """The case's nonce: the kept one if the secret file exists, otherwise a fresh one, saved once.""" + existing = load_secret_nonce(state_path) + if existing is not None: + return existing + nonce = generate_secret_nonce() + save_secret_nonce(state_path, nonce) + return nonce + + +# ── benchmark marks ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Mark: + step: str + elapsed_ms: int + total_ms: int + + +@dataclass +class LiveBenchmark: + """veil ``createLiveBenchmark()``: per-step and total elapsed milliseconds, printed as they happen. + + ``marks`` is kept so a pytest case can hand the whole timeline to ``record_property``. + """ + + label: str + now: Callable[[], float] = time.monotonic + log: Callable[[str], None] = print + marks: list[Mark] = field(default_factory=list) + + def __post_init__(self) -> None: + self._started = self.now() + self._previous = self._started + + def mark(self, step: str) -> Mark: + current = self.now() + entry = Mark(step, round((current - self._previous) * 1000), round((current - self._started) * 1000)) + self._previous = current + self.marks.append(entry) + self.log(f"[{self.label}] {step}: +{entry.elapsed_ms}ms (total {entry.total_ms}ms)") + return entry + + def summary(self) -> str: + if not self.marks: + return f"{self.label}: no marks" + steps = ", ".join(f"{m.step} +{m.elapsed_ms}ms" for m in self.marks) + return f"{self.label}: {steps} (total {self.marks[-1].total_ms}ms)" + + def as_dict(self) -> dict[str, int]: + return {m.step: m.elapsed_ms for m in self.marks} + + +# ── polling ─────────────────────────────────────────────────────────────────── + +def wait_for(read: Callable[[], Any], *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic) -> Any: + """veil ``waitFor()``: poll *read* until it returns something that is not None. + + Always reads at least once, then checks the deadline — so a zero timeout still performs one + read. A timeout raises :class:`LiveTimeoutError`; the transfer is not failed, it is unfinished, + and the state file on disk is how you resume it. + """ + deadline = now() + timeout_seconds + while True: + value = read() + if value is not None: + return value + if now() >= deadline: + raise LiveTimeoutError( + "Live bridge verification timed out; the transfer is still in flight — " + "resume from the persisted state file") + sleep(poll_seconds) + + +# ── Hyperlane explorer (read-only HTTP) ─────────────────────────────────────── + +@dataclass(frozen=True) +class HyperlaneDelivery: + message_id: str + destination_tx_id: str + + +def _post(url: str, payload: dict[str, Any], timeout: float) -> Any: + import requests + + return requests.post(url, json=payload, timeout=timeout, + headers={"content-type": "application/json"}) + + +def _bytea(source_tx_id: str) -> str: + """veil helpers.ts:81-83: an EVM ``0x`` hash or a Solana base58 signature → PostgreSQL bytea.""" + if source_tx_id.startswith("0x"): + return f"\\x{source_tx_id[2:]}" + return f"\\x{b58decode(source_tx_id).hex()}" + + +def _normalize(value: str) -> str: + return f"0x{value[2:]}" if value.startswith("\\x") else value + + +def hyperlane_delivery(source_tx_id: str, *, post: Callable[..., Any] = _post, + timeout: float = 30.0) -> HyperlaneDelivery | None: + """One read of the Hyperlane explorer for the message dispatched by *source_tx_id*. + + Returns None while the message is undelivered AND when the explorer is throttled or broken + (HTTP 429/5xx, or an unreachable host): a rate-limited explorer says nothing about the + transfer. A GraphQL error is a bug in the query and is raised (veil helpers.ts:104-106). + """ + payload = {"query": HYPERLANE_QUERY, "variables": {"hash": _bytea(source_tx_id)}} + try: + response = post(HYPERLANE_EXPLORER_URL, payload, timeout) + except Exception: # noqa: BLE001 — network flake, never a verdict + return None + status = getattr(response, "status_code", 200) + if status == 429 or status >= 500: + return None + if status >= 400: + raise ExplorerError(f"Hyperlane explorer returned HTTP {status}") + body = response.json() + errors = body.get("errors") if isinstance(body, dict) else None + if errors: + joined = "; ".join(str(e.get("message", "unknown error")) for e in errors) + raise ExplorerError(f"Hyperlane explorer query failed: {joined}") + rows = ((body.get("data") or {}).get("message_view") or []) if isinstance(body, dict) else [] + message = rows[0] if rows else None + if not message or not message.get("is_delivered") or not message.get("msg_id") \ + or not message.get("destination_tx_hash"): + return None + return HyperlaneDelivery(message_id=_normalize(message["msg_id"]), + destination_tx_id=_normalize(message["destination_tx_hash"])) + + +def wait_for_hyperlane_delivery(source_tx_id: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic, + post: Callable[..., Any] = _post) -> HyperlaneDelivery | None: + """Poll the explorer for the destination transaction id, or give up quietly. + + This lookup is a convenience on top of a leg the SDK has already called ``done``, so a timeout + or a throttled explorer returns None instead of failing the case (veil parity §8). + """ + try: + return wait_for(lambda: hyperlane_delivery(source_tx_id, post=post), + timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, sleep=sleep, now=now) + except LiveTimeoutError: + return None + + +# ── Aleo confirmation ───────────────────────────────────────────────────────── + +def wait_for_aleo_transaction(bridge: Any, tx_id: str, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + poll_seconds: float = DEFAULT_POLL_SECONDS, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic) -> None: + """veil ``waitForAleoTransaction()``: block until *tx_id* is accepted; a rejection raises.""" + + def read() -> Any: + status, error = aleo_transaction_status(bridge, tx_id) + if status == "rejected": + raise LiveCaseError(error or f"Aleo transaction {tx_id} was rejected") + return True if status == "accepted" else None + + wait_for(read, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds, sleep=sleep, now=now) + + +def redacted(values: Iterable[str]) -> str: + """A stable, non-reversible tag for a secret-bearing string (a record plaintext), for logs.""" + import hashlib + + digest = hashlib.sha256("".join(values).encode("utf-8")).hexdigest() + return f"sha256:{digest[:12]}" + + +__all__ = [ + "DEFAULT_POLL_SECONDS", "DEFAULT_TIMEOUT_SECONDS", "ExplorerError", "HYPERLANE_EXPLORER_URL", + "HYPERLANE_QUERY", "HyperlaneDelivery", "LiveBenchmark", "LiveCaseError", "LiveState", + "LiveStateError", "LiveTimeoutError", "Mark", "Underfunded", "ensure_secret_nonce", + "generate_secret_nonce", "hyperlane_delivery", "load_live_state", "load_secret_nonce", "redacted", + "save_live_state", "save_secret_nonce", "secret_path", "wait_for", "wait_for_aleo_transaction", + "wait_for_hyperlane_delivery", +] diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py new file mode 100644 index 00000000..d9c0614d --- /dev/null +++ b/bridge-sdk/tests/test_live_helpers.py @@ -0,0 +1,407 @@ +"""Hermetic tests for the live-funds harness itself (port of veil `test/integration/live/helpers.test.ts`). + +Nothing here touches a network, a key, or a chain: the gates are exercised with a monkeypatched +environment, the state files in ``tmp_path``, the Hyperlane explorer through an injected HTTP +callable, and the rehearsal CLI against ``FakeBridge``. The harness is tested BEFORE the funded +cases can run it, which is the whole point of the file: a bug in the gate is a bug that spends +real money. +""" +from __future__ import annotations + +import json +import os +import stat + +import pytest + +from tests.live import config as live_config +from tests.live import helpers as live_helpers + +FUNDS = "BRIDGE_LIVE_FUNDS" +STATE_DIR = "BRIDGE_LIVE_STATE_DIR" +ACK = "BRIDGE_LIVE_MAINNET_ACK" +CASES = "BRIDGE_LIVE_MAINNET_CASES" +EXECUTE = "BRIDGE_LIVE_MAINNET_EXECUTE" + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Every gate variable starts unset: the truth tables below set exactly what they test.""" + for name in (FUNDS, STATE_DIR, ACK, CASES, EXECUTE, "TEST_EVM_KEY"): + monkeypatch.delenv(name, raising=False) + return monkeypatch + + +# ── config.py: reading required values without ever logging them ────────────── + +def test_required_names_the_variable_and_never_the_value(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", " value-with-space ") + assert live_config.required("TEST_EVM_KEY") == "value-with-space" + + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.required("BRIDGE_LIVE_ABSENT") + assert "BRIDGE_LIVE_ABSENT" in str(excinfo.value) + + +def test_required_treats_whitespace_only_as_missing(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", " ") + with pytest.raises(live_config.LiveConfigError): + live_config.required("TEST_EVM_KEY") + + +def test_normalizes_prefixed_and_unprefixed_evm_private_keys(monkeypatch): + """veil helpers.test.ts:14-20 — the 0x prefix is optional, the value is never echoed.""" + key = "ab" * 32 + monkeypatch.setenv("TEST_EVM_KEY", key) + assert live_config.required_evm_private_key("TEST_EVM_KEY") == f"0x{key}" + monkeypatch.setenv("TEST_EVM_KEY", f"0X{key}") + assert live_config.required_evm_private_key("TEST_EVM_KEY") == f"0x{key}" + + +def test_rejects_a_malformed_evm_private_key_without_printing_it(monkeypatch): + monkeypatch.setenv("TEST_EVM_KEY", "deadbeef") + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.required_evm_private_key("TEST_EVM_KEY") + message = str(excinfo.value) + assert "TEST_EVM_KEY" in message and "32" in message and "deadbeef" not in message + + +# ── config.py: the gate truth table ─────────────────────────────────────────── + +def test_live_funds_needs_both_the_flag_and_a_state_dir(monkeypatch): + assert live_config.live_funds_enabled() is False + monkeypatch.setenv(FUNDS, "1") + assert live_config.live_funds_enabled() is False # no state dir + monkeypatch.setenv(STATE_DIR, "/tmp/bridge-state") + assert live_config.live_funds_enabled() is True + monkeypatch.setenv(FUNDS, "true") + assert live_config.live_funds_enabled() is False # exactly "1", like veil + + +def test_mainnet_case_requires_funding_state_acknowledgement_and_the_named_case(monkeypatch): + """veil helpers.test.ts:56-64.""" + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(STATE_DIR, "/tmp/bridge-state") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-xreserve, aleo-hyperlane") + + assert live_config.mainnet_case_enabled("evm-xreserve") is True + assert live_config.mainnet_case_enabled("aleo-hyperlane") is True + assert live_config.mainnet_case_enabled("solana-hyperlane") is False + + monkeypatch.setenv(ACK, "yes") + assert live_config.mainnet_case_enabled("evm-xreserve") is False + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.delenv(FUNDS) + assert live_config.mainnet_case_enabled("evm-xreserve") is False + + +def test_execution_requires_a_separate_exact_acknowledgement(monkeypatch): + """veil helpers.test.ts:66-71 — nothing in this repo ever SETS this variable.""" + assert live_config.mainnet_execution_enabled() is False + monkeypatch.setenv(EXECUTE, "yes") + assert live_config.mainnet_execution_enabled() is False + monkeypatch.setenv(EXECUTE, "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS") + assert live_config.mainnet_execution_enabled() is True + + +def test_gates_only_read_the_environment(monkeypatch): + """No gate may write, default or repair a variable — a gate that sets its own key is not a gate.""" + before = dict(os.environ) + live_config.live_funds_enabled() + live_config.mainnet_case_enabled("evm-hyperlane") + live_config.mainnet_execution_enabled() + assert dict(os.environ) == before + + +def test_case_names_are_veils_five_mainnet_cases(): + assert live_config.CASE_NAMES == ("evm-hyperlane", "evm-xreserve", "aleo-hyperlane", + "aleo-xreserve", "solana-hyperlane") + + +def test_one_atomic_unit_per_asset_precision(): + """veil helpers.test.ts:73-77.""" + assert live_config.one_atomic_unit(0) == "1" + assert live_config.one_atomic_unit(6) == "0.000001" + assert live_config.one_atomic_unit(9) == "0.000000001" + assert live_config.one_atomic_unit(18) == "0.000000000000000001" + for bad in (-1, 1.5, True): + with pytest.raises(live_config.LiveConfigError): + live_config.one_atomic_unit(bad) + + +def test_live_state_path_is_namespaced_by_environment(monkeypatch, tmp_path): + monkeypatch.setenv(STATE_DIR, str(tmp_path)) + assert live_config.live_state_path("mainnet", "evm-xreserve") == tmp_path / "mainnet" / "evm-xreserve.json" + with pytest.raises(live_config.LiveConfigError): + live_config.live_state_path("devnet", "evm-xreserve") + monkeypatch.delenv(STATE_DIR) + with pytest.raises(live_config.LiveConfigError): + live_config.live_state_path("mainnet", "evm-xreserve") + + +def test_route_and_recipient_overrides_are_optional(monkeypatch): + assert live_config.case_route_override("evm-hyperlane") is None + monkeypatch.setenv("BRIDGE_LIVE_EVM_HYPERLANE_ROUTE_ID", "hyperlane:ethereum/wbtc->aleo/wbtc") + assert live_config.case_route_override("evm-hyperlane") == "hyperlane:ethereum/wbtc->aleo/wbtc" + + assert live_config.recipient_override("aleo") is None + monkeypatch.setenv("BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT", "aleo1recipient") + assert live_config.recipient_override("aleo") == "aleo1recipient" + with pytest.raises(live_config.LiveConfigError): + live_config.recipient_override("bitcoin") + + +# ── helpers.py: state files ─────────────────────────────────────────────────── + +def test_state_round_trips_and_starts_empty_for_an_absent_file(tmp_path): + """veil helpers.test.ts:34-41.""" + path = tmp_path / "mainnet" / "state.json" + assert live_helpers.load_live_state(path, "route:a") == live_helpers.LiveState(route_id="route:a") + + state = live_helpers.LiveState(route_id="route:a", source_tx_id="source-1") + live_helpers.save_live_state(path, state) + assert live_helpers.load_live_state(path, "route:a") == state + + +def test_state_keeps_every_recorded_field_across_a_reload(tmp_path): + path = tmp_path / "state.json" + state = live_helpers.LiveState( + route_id="route:a", source_tx_id="0xsource", message_id="0xmessage", + destination_tx_id="at1destination", destination_balance_before="1000", completed=True, + checkpoint={"version": 1, "receiptId": "r"}, secret_nonce_present=True) + live_helpers.save_live_state(path, state) + assert live_helpers.load_live_state(path, "route:a") == state + assert json.loads(path.read_text())["routeId"] == "route:a" # veil's on-disk key names + + +def test_state_files_are_owner_only_and_written_atomically(tmp_path): + path = tmp_path / "nested" / "state.json" + live_helpers.save_live_state(path, live_helpers.LiveState(route_id="route:a")) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + assert not list(path.parent.glob("*.tmp")) + + +def test_state_fails_closed_for_corrupt_malformed_or_wrong_route_files(tmp_path): + """veil helpers.test.ts:43-52 — a state file we cannot trust never becomes a fresh run.""" + path = tmp_path / "state.json" + + path.write_text("{") + with pytest.raises(live_helpers.LiveStateError): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps([1, 2])) + with pytest.raises(live_helpers.LiveStateError): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:b", "sourceTxId": "x"})) + with pytest.raises(live_helpers.LiveStateError, match="does not match"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "sourceTxId": 1})) + with pytest.raises(live_helpers.LiveStateError, match="sourceTxId"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "completed": "yes"})) + with pytest.raises(live_helpers.LiveStateError, match="completed"): + live_helpers.load_live_state(path, "route:a") + + path.write_text(json.dumps({"routeId": "route:a", "checkpoint": "not-an-object"})) + with pytest.raises(live_helpers.LiveStateError, match="checkpoint"): + live_helpers.load_live_state(path, "route:a") + + +# ── helpers.py: the secret nonce lives beside the state, never inside it ────── + +def test_secret_nonce_is_a_valid_scalar_and_freshly_random(): + from aleo_bridge.encoding import validate_scalar + + first = live_helpers.generate_secret_nonce() + assert validate_scalar(first) == first + assert first != live_helpers.generate_secret_nonce() + + +def test_secret_file_is_exclusive_owner_only_and_absent_from_the_state(tmp_path): + path = tmp_path / "state.json" + nonce = live_helpers.ensure_secret_nonce(path) + secret = live_helpers.secret_path(path) + + assert secret.name.endswith(".secret") + assert stat.S_IMODE(secret.stat().st_mode) == 0o600 + assert live_helpers.load_secret_nonce(path) == nonce + assert live_helpers.ensure_secret_nonce(path) == nonce # stable across invocations + + with pytest.raises(FileExistsError): + live_helpers.save_secret_nonce(path, nonce) # O_CREAT|O_EXCL: never overwritten + + live_helpers.save_live_state(path, live_helpers.LiveState(route_id="route:a", secret_nonce_present=True)) + assert nonce not in path.read_text() + assert json.loads(path.read_text())["secretNoncePresent"] is True + + +def test_missing_secret_file_reads_as_none(tmp_path): + assert live_helpers.load_secret_nonce(tmp_path / "state.json") is None + + +# ── helpers.py: benchmark marks ─────────────────────────────────────────────── + +def test_benchmark_reports_per_step_and_total_elapsed_time(): + """veil helpers.test.ts:22-32 (millisecond deltas from an injected clock).""" + times = iter([1.000, 1.250, 1.900]) + lines: list[str] = [] + benchmark = live_helpers.LiveBenchmark("route", now=lambda: next(times), log=lines.append) + + benchmark.mark("quote-returned") + benchmark.mark("execute-returned") + + assert lines == ["[route] quote-returned: +250ms (total 250ms)", + "[route] execute-returned: +650ms (total 900ms)"] + assert [m.step for m in benchmark.marks] == ["quote-returned", "execute-returned"] + assert [m.elapsed_ms for m in benchmark.marks] == [250, 650] + assert benchmark.summary() == "route: quote-returned +250ms, execute-returned +650ms (total 900ms)" + + +def test_benchmark_summary_of_a_run_that_marked_nothing(): + benchmark = live_helpers.LiveBenchmark("route", now=lambda: 0.0, log=lambda _: None) + assert benchmark.summary() == "route: no marks" + + +# ── helpers.py: polling ─────────────────────────────────────────────────────── + +def test_wait_for_returns_the_first_non_none_read_and_sleeps_between_polls(): + reads = iter([None, None, "value"]) + slept: list[float] = [] + clock = iter([0.0, 15.0, 30.0, 45.0]) + + value = live_helpers.wait_for(lambda: next(reads), timeout_seconds=600, poll_seconds=15, + sleep=slept.append, now=lambda: next(clock)) + assert value == "value" and slept == [15, 15] + + +def test_wait_for_raises_at_the_deadline_and_names_the_state_file(): + clock = iter([0.0, 5.0, 10.0, 10.0]) + with pytest.raises(live_helpers.LiveTimeoutError, match="state file"): + live_helpers.wait_for(lambda: None, timeout_seconds=10, poll_seconds=1, + sleep=lambda _: None, now=lambda: next(clock)) + + +def test_wait_for_always_reads_at_least_once_even_with_a_zero_timeout(): + calls = [] + + def read(): + calls.append(1) + return "immediate" + + assert live_helpers.wait_for(read, timeout_seconds=0, sleep=lambda _: None) == "immediate" + assert calls == [1] + + +# ── helpers.py: the Hyperlane explorer (read-only HTTP) ─────────────────────── + +class _Response: + def __init__(self, payload, status_code=200): + self._payload, self.status_code = payload, status_code + + def json(self): + return self._payload + + +DELIVERED = {"data": {"message_view": [{"msg_id": "\\xmessage", "is_delivered": True, + "destination_tx_hash": "\\xdestination"}]}} + + +def test_hyperlane_lookup_uses_bytea_hashes_and_normalizes_the_result(): + """veil helpers.test.ts:81-103 — the explorer speaks PostgreSQL bytea, the SDK speaks 0x.""" + seen = {} + + def post(url, payload, timeout): + seen.update(url=url, payload=payload) + return _Response(DELIVERED) + + delivery = live_helpers.hyperlane_delivery("0xsource", post=post) + assert delivery == live_helpers.HyperlaneDelivery(message_id="0xmessage", destination_tx_id="0xdestination") + assert seen["url"] == live_helpers.HYPERLANE_EXPLORER_URL + assert "$hash: bytea!" in seen["payload"]["query"] + assert seen["payload"]["variables"]["hash"] == "\\xsource" + + +def test_hyperlane_lookup_decodes_a_solana_base58_signature(): + """veil helpers.test.ts:105-126.""" + seen = {} + + def post(url, payload, timeout): + seen.update(payload=payload) + return _Response(DELIVERED) + + live_helpers.hyperlane_delivery( + "QrRfJM8xSiKgvqgd8PeiYTgyA7EkLbzKSnEn5wV6amxA4P15cQY41Vh4H85km8RvTX5pDph6oKxhVzsewdGhdnM", post=post) + assert seen["payload"]["variables"]["hash"] == ( + "\\x1491b6d2018d56b09ce9e368e701ccfc618485ff784f6419fe72d660a4a992d5" + "f5d0a4392bf75b8172f57faeea28c3e660c0e9544e4320fb9f4df4d9cce9da06") + + +def test_hyperlane_lookup_is_none_while_the_message_is_undelivered(): + undelivered = {"data": {"message_view": [{"msg_id": "\\xmessage", "is_delivered": False}]}} + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response(undelivered)) is None + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response({"data": {"message_view": []}})) is None + + +@pytest.mark.parametrize("status", [429, 500, 502, 503]) +def test_hyperlane_lookup_returns_none_when_the_explorer_rate_limits_or_fails(status): + """A throttled explorer is an environment condition, never a verdict on the transfer.""" + assert live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response({}, status)) is None + + +def test_hyperlane_lookup_surfaces_graphql_errors(): + """veil helpers.test.ts:128-136 — a bad query must not poll silently until the timeout.""" + errors = {"errors": [{"message": "invalid bytea input"}]} + with pytest.raises(live_helpers.ExplorerError, match="invalid bytea input"): + live_helpers.hyperlane_delivery("0xsource", post=lambda *a, **k: _Response(errors)) + + +def test_wait_for_hyperlane_delivery_gives_up_quietly_rather_than_failing_a_done_leg(): + """The leg is already `done`; the destination-tx lookup is a convenience, not an assertion.""" + assert live_helpers.wait_for_hyperlane_delivery( + "0xsource", post=lambda *a, **k: _Response({}, 429), + timeout_seconds=0, poll_seconds=0, sleep=lambda _: None) is None + + delivery = live_helpers.wait_for_hyperlane_delivery( + "0xsource", post=lambda *a, **k: _Response(DELIVERED), + timeout_seconds=60, poll_seconds=0, sleep=lambda _: None) + assert delivery.destination_tx_id == "0xdestination" + + +# ── helpers.py: Aleo confirmation ───────────────────────────────────────────── + +class _StatusBridge: + def __init__(self, statuses): + self.statuses = iter(statuses) + self.seen: list[str] = [] + + def status_of(self, tx_id): + self.seen.append(tx_id) + return next(self.statuses) + + +def test_wait_for_aleo_transaction_accepts_after_pending(monkeypatch): + bridge = _StatusBridge([("pending", None), ("accepted", None)]) + monkeypatch.setattr(live_helpers, "aleo_transaction_status", lambda b, tx: b.status_of(tx)) + live_helpers.wait_for_aleo_transaction(bridge, "at1x", timeout_seconds=60, poll_seconds=0, + sleep=lambda _: None) + assert bridge.seen == ["at1x", "at1x"] + + +def test_wait_for_aleo_transaction_raises_on_a_rejected_transaction(monkeypatch): + """veil helpers.test.ts:139-150 — a rejected transaction is a failure, not a slow confirmation.""" + bridge = _StatusBridge([("rejected", "Aleo transaction at1rejected was rejected by the network")]) + monkeypatch.setattr(live_helpers, "aleo_transaction_status", lambda b, tx: b.status_of(tx)) + with pytest.raises(live_helpers.LiveCaseError, match="rejected"): + live_helpers.wait_for_aleo_transaction(bridge, "at1rejected", timeout_seconds=60, poll_seconds=0, + sleep=lambda _: None) + + +def test_underfunded_carries_the_shortfall(): + error = live_helpers.Underfunded(asset_id="ethereum/usdc", needed=2_000_000, have=1_500_000) + assert error.shortfall == 500_000 + assert "ethereum/usdc" in str(error) and "500000" in str(error) From a2df67863edb2e6c2c09c3a7e3245a0c938d2fc8 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 15:01:11 -0400 Subject: [PATCH 88/94] feat(bridge-sdk): live cases + rehearse CLI over the public lifecycle verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/scripts/rehearse.py | 230 +++++++++++++ bridge-sdk/tests/live/cases.py | 460 ++++++++++++++++++++++++++ bridge-sdk/tests/test_live_helpers.py | 444 +++++++++++++++++++++++++ 3 files changed, 1134 insertions(+) create mode 100644 bridge-sdk/scripts/rehearse.py create mode 100644 bridge-sdk/tests/live/cases.py diff --git a/bridge-sdk/scripts/rehearse.py b/bridge-sdk/scripts/rehearse.py new file mode 100644 index 00000000..5efb224b --- /dev/null +++ b/bridge-sdk/scripts/rehearse.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Operator front-end over the live cases in ``tests/live/cases.py``. + + python scripts/rehearse.py --case evm-hyperlane --quote-only # price every route, submit nothing + python scripts/rehearse.py --case evm-hyperlane --route hyperlane:ethereum/eth->aleo/eth + python scripts/rehearse.py --recover # continue one interrupted transfer + python scripts/rehearse.py --case solana-hyperlane --report run.json + +The CLI and the pytest suite call the SAME case functions, so a rehearsal and a test cannot drift. + +This script never sets, exports, prints or suggests a value for an acknowledgement variable. It +READS ``BRIDGE_LIVE_FUNDS`` / ``BRIDGE_LIVE_STATE_DIR`` / ``BRIDGE_LIVE_MAINNET_ACK`` / +``BRIDGE_LIVE_MAINNET_CASES`` / ``BRIDGE_LIVE_MAINNET_EXECUTE``; without all of them it runs to the +quote and reports what it would have submitted. Keys, secret nonces, attestations and record +plaintexts never reach the output — addresses, amounts and transaction ids do. + +Exit codes: ``0`` everything ran (or was quoted/skipped), ``1`` a case failed, ``2`` a case is +still pending (a timeout leaves the checkpoint on disk; re-run with ``--recover``). +""" +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Iterable + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(ROOT / "python") not in sys.path: + sys.path.insert(0, str(ROOT / "python")) + +from aleo_bridge.errors import BridgeError, PollingTimeoutError # noqa: E402 +from tests.live import cases as live_cases # noqa: E402 +from tests.live import config as live_config # noqa: E402 +from tests.live.helpers import (LiveBenchmark, LiveCaseError, LiveTimeoutError, # noqa: E402 + Underfunded, load_live_state) + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_PENDING = 2 + +_COLUMNS = (("case", 16), ("route_id", 40), ("status", 11), ("source_tx_id", 18), ("reason", 40)) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="rehearse.py", description="Run one live bridge case over its registry routes.", + epilog="Mainnet submission additionally requires the acknowledgement variables described " + "in the README's 'Live tests' section; this script only reads them.") + parser.add_argument("--case", choices=list(live_cases.CASE_NAMES), + help="the veil case to run (omit only with --recover)") + parser.add_argument("--route", help="restrict the run to one registry route id") + parser.add_argument("--quote-only", action="store_true", + help="price and preflight every route; never submit, whatever is acknowledged") + parser.add_argument("--recover", metavar="STATE.json", + help="continue the transfer recorded in one state file (its route picks the case)") + parser.add_argument("--report", metavar="PATH", help="write the JSON report here") + args = parser.parse_args(argv) + if not args.case and not args.recover: + parser.error("give --case NAME or --recover STATE.json") + return args + + +# ── target selection ────────────────────────────────────────────────────────── + +def resolve_routes(registry: Any, case: str, route: str | None = None, + environment: str = "mainnet") -> list[Any]: + """The routes this invocation covers: every route of *case*, or the single one asked for.""" + routes = live_cases.routes_for_case(registry, case, environment) + if route is None: + return routes + chosen = [candidate for candidate in routes if candidate.id == route] + if not chosen: + raise SystemExit(f"Route {route} is not one of the {case} routes for {environment}") + return chosen + + +def execution_allowed(case: str, *, quote_only: bool) -> tuple[bool, str]: + """Whether the wallet may submit, and the reason when it may not (variable names only).""" + if quote_only: + return False, "--quote-only was given" + if not live_config.live_funds_enabled(): + return False, (f"{live_config.FUNDS_VAR} and {live_config.STATE_DIR_VAR} do not enable " + "funded live cases") + if not live_config.mainnet_case_enabled(case): + return False, (f"{live_config.MAINNET_ACK_VAR} and {live_config.MAINNET_CASES_VAR} do not " + f"enable the {case} case") + if not live_config.mainnet_execution_enabled(): + return False, f"{live_config.MAINNET_EXECUTE_VAR} does not acknowledge mainnet submission" + return True, "acknowledged" + + +def state_path_for(case: str, route_id: str, environment: str) -> Path: + return live_config.live_state_path(environment, live_cases.state_name(case, route_id)) + + +def resume_command(state_path: Path | str) -> str: + return f"python scripts/rehearse.py --recover {state_path}" + + +# ── running ─────────────────────────────────────────────────────────────────── + +def _row(case: str, route_id: str, status: str, reason: str = "", state: Any = None, + state_path: Path | str | None = None, benchmark: LiveBenchmark | None = None) -> dict[str, Any]: + return { + "case": case, + "route_id": route_id, + "status": status, + "reason": reason, + "source_tx_id": getattr(state, "source_tx_id", None), + "message_id": getattr(state, "message_id", None), + "destination_tx_id": getattr(state, "destination_tx_id", None), + "state_path": str(state_path) if state_path else None, + "resume": resume_command(state_path) if state_path else "", + "benchmark": benchmark.as_dict() if benchmark is not None else {}, + } + + +def run_route(bridge: Any, case: str, route: Any, *, execute: bool, state_path: Path, + log: Callable[[str], None] = print) -> dict[str, Any]: + """One route: run the case, and turn every outcome into a report row rather than a traceback.""" + if not route.active: + return _row(case, route.id, "skipped", f"registry availability: {route.availability}") + benchmark = LiveBenchmark(f"{case}:{live_cases.route_slug(route.id)}", log=log) + runner = live_cases.RUNNERS[case] + try: + state = runner(bridge, route.id, state_path=state_path, execute=execute, benchmark=benchmark, + log=log) + except Underfunded as exc: + return _row(case, route.id, "skipped", str(exc), state_path=state_path, benchmark=benchmark) + except (PollingTimeoutError, LiveTimeoutError) as exc: + return _row(case, route.id, "pending", f"still in flight: {exc}", state_path=state_path, + benchmark=benchmark) + except (LiveCaseError, BridgeError) as exc: + return _row(case, route.id, "failed", f"{type(exc).__name__}: {exc}", state_path=state_path, + benchmark=benchmark) + status = "completed" if state.completed else ("quote-only" if not execute else "pending") + reason = "" if status != "pending" else "the transfer has not reached done yet" + return _row(case, route.id, status, reason, state=state, state_path=state_path, benchmark=benchmark) + + +def _cell(value: Any, width: int) -> str: + text = str(value or "") + return text.ljust(width) if len(text) <= width else text[:width - 1] + "…" + + +def render_table(rows: Iterable[dict[str, Any]]) -> str: + """A fixed-width table of what ran, was skipped, is pending, or failed.""" + header = " ".join(name.replace("_", " ").upper().ljust(width) for name, width in _COLUMNS) + lines = [header, "-" * len(header)] + lines.extend(" ".join(_cell(row.get(name), width) for name, width in _COLUMNS) for row in rows) + return "\n".join(lines) + "\n" + + +def exit_code(rows: list[dict[str, Any]]) -> int: + statuses = {row["status"] for row in rows} + if "failed" in statuses: + return EXIT_FAILED + if "pending" in statuses: + return EXIT_PENDING + return EXIT_OK + + +def _default_bridge() -> Any: + from aleo_bridge import Bridge + + # Bridge.from_env() already reads BRIDGE_LIVE_ETHEREUM_RPC_URL / BRIDGE_LIVE_SOLANA_RPC_URL as + # aliases of ETHEREUM_RPC_URL / SOLANA_RPC_URL, so veil's shell works unchanged. + return Bridge.from_env() + + +def run(argv: list[str] | None = None, *, bridge_factory: Callable[[], Any] = _default_bridge, + log: Callable[[str], None] = print) -> int: + args = parse_args(argv) + bridge = bridge_factory() + environment = bridge.environment + + if args.recover: + state_path = Path(args.recover).expanduser().resolve() + route_id = json.loads(state_path.read_text(encoding="utf-8")).get("routeId") + if not isinstance(route_id, str): + raise SystemExit(f"{state_path} does not name a routeId") + route = bridge.registry.route(route_id) + case = args.case or live_cases.case_for_route(bridge.registry, route) + if case is None: + raise SystemExit(f"No live case covers {route_id}") + load_live_state(state_path, route_id) # fail closed before anything else + targets = [(route, state_path)] + else: + case = args.case + targets = [(route, state_path_for(case, route.id, environment)) + for route in resolve_routes(bridge.registry, case, args.route, environment)] + + execute, reason = execution_allowed(case, quote_only=args.quote_only) + log(f"case {case} · environment {environment} · registry {bridge.registry.version}") + log(f"submission: {'ENABLED' if execute else 'disabled'} ({reason})") + if not execute and not args.quote_only: + log("Nothing will be submitted. Mainnet submission is gated on the acknowledgement variables " + f"({live_config.MAINNET_ACK_VAR}, {live_config.MAINNET_CASES_VAR}, " + f"{live_config.MAINNET_EXECUTE_VAR}); set them yourself, for one command, in your own shell.") + + rows = [run_route(bridge, case, route, execute=execute, state_path=path, log=log) + for route, path in targets] + + log("") + log(render_table(rows)) + for row in rows: + if row["status"] == "pending": + log(f"pending {row['route_id']}: {row['resume']}") + + payload = {"generated_at": datetime.now(timezone.utc).isoformat(), "case": case, + "environment": environment, "registry_version": bridge.registry.version, + "execute": execute, "reason": reason, "results": rows} + if args.report: + report = Path(args.report).expanduser() + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + log(f"report written to {report}") + return exit_code(rows) + + +def main() -> int: + return run() + + +if __name__ == "__main__": # pragma: no cover — exercised through run() + raise SystemExit(main()) diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py new file mode 100644 index 00000000..f7ea8f4e --- /dev/null +++ b/bridge-sdk/tests/live/cases.py @@ -0,0 +1,460 @@ +"""The funded live cases — one function per veil case, driven only by the public ``Bridge`` verbs. + +Ported from veil's `test/integration/live/mainnet/*.live.test.ts` (and `testnet/evm-xreserve`): +five cases, each parametrized over every registry route it covers, each resuming from its own +state file. The pytest suite and ``scripts/rehearse.py`` both call these functions, so the two can +never drift. + +Shape of every case (veil parity §4): + +1. load the state file for this route (fails closed; a completed case re-asserts and returns), +2. ``quote`` → print the route/amount/fee table → balance precheck (:class:`Underfunded` when the + wallet cannot cover it, which callers turn into a skip), +3. ``execute=False`` stops here — that is veil's ``if (!mainnetExecutionEnabled()) return``, +4. otherwise ``execute(plan, on_checkpoint=…)`` once, saving the checkpoint to the state file at + every boundary, then drop the in-memory progress and ``recover`` from what is on disk, +5. drive ``wait`` / ``resume`` / ``complete`` until ``next == "done"`` (``failed`` raises), +6. record the source tx, message id, destination tx and balance delta; mark the state completed. + +What this module will never do: retry ``execute`` after a broadcast (ambiguous or not), read or +set an acknowledgement variable (the caller passes ``execute=``), or print a key, a secret nonce, +an attestation or a record plaintext. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from aleo_bridge.errors import InsufficientBalanceError +from aleo_bridge.privacy import record_amount +from aleo_bridge.registry import Asset, Registry, Route +from aleo_bridge.units import format_decimal_amount + +from .config import one_atomic_unit +from .helpers import (LiveBenchmark, LiveCaseError, LiveState, Underfunded, ensure_secret_nonce, + load_live_state, load_secret_nonce, redacted, save_live_state, + wait_for_hyperlane_delivery) + +#: veil's per-test budget (`30 * 60_000`). +CASE_TIMEOUT_SECONDS = 30 * 60.0 +WAIT_TIMEOUT_SECONDS = 20 * 60.0 +WAIT_POLL_SECONDS = 15.0 +_MAX_TRANSITIONS = 12 # a drive loop that cannot settle is a bug, not a slow chain + + +@dataclass(frozen=True) +class CaseSpec: + """One veil case: which routes it covers, what it sends, and how ``execute`` is parametrized.""" + + name: str + protocol: str # "hyperlane" | "xreserve" + source_family: str # "evm" | "aleo" | "solana" + mint_mode: str = "public" # "private" for the xReserve private mint into Aleo + mode: str | None = None # execute(mode=…): "signer" (Aleo Hyperlane) / "private" (Aleo burn) + amount: str | None = None # veil's literal, or None → one atomic unit of the source asset + veil_source: str = "" + + @property + def private_mint(self) -> bool: + return self.mint_mode == "private" + + +CASES: dict[str, CaseSpec] = { + "evm-hyperlane": CaseSpec( + name="evm-hyperlane", protocol="hyperlane", source_family="evm", + veil_source="mainnet/evm-hyperlane.live.test.ts:21-115"), + "evm-xreserve": CaseSpec( + name="evm-xreserve", protocol="xreserve", source_family="evm", mint_mode="private", + amount="2", # veil mainnet/evm-xreserve.live.test.ts:57 + veil_source="mainnet/evm-xreserve.live.test.ts:31-146"), + "aleo-hyperlane": CaseSpec( + name="aleo-hyperlane", protocol="hyperlane", source_family="aleo", mode="signer", + veil_source="mainnet/aleo-hyperlane.live.test.ts:47-164"), + "aleo-xreserve": CaseSpec( + name="aleo-xreserve", protocol="xreserve", source_family="aleo", mode="private", + amount="2.000001", # veil mainnet/aleo-xreserve.live.test.ts:97 + veil_source="mainnet/aleo-xreserve.live.test.ts:69-156"), + "solana-hyperlane": CaseSpec( + name="solana-hyperlane", protocol="hyperlane", source_family="solana", + veil_source="mainnet/solana-hyperlane.live.test.ts:20-91"), +} + +CASE_NAMES = tuple(CASES) + + +# ── routes, names, defaults ─────────────────────────────────────────────────── + +def routes_for_case(registry: Registry, case: str, environment: str = "mainnet") -> list[Route]: + """Every registry route *case* covers — both directions are separate cases, so this is one way. + + Includes non-active routes so the caller can report them as skipped-by-registry rather than + silently dropping them (veil parity §2). + """ + spec = CASES[case] + return [route for route in registry.routes(environment=environment, protocol=spec.protocol) + if registry.chain(registry.asset(route.source_asset_id).chain_id).family == spec.source_family] + + +def case_for_route(registry: Registry, route: Route) -> str | None: + """The case that covers *route*, or None — 13b asserts no active mainnet route returns None.""" + family = registry.chain(registry.asset(route.source_asset_id).chain_id).family + for spec in CASES.values(): + if spec.protocol == route.protocol and spec.source_family == family: + return spec.name + return None + + +def route_slug(route_id: str) -> str: + """``hyperlane:ethereum/eth->aleo/eth`` → ``hyperlane-ethereum-eth-aleo-eth`` (veil's state-name rule).""" + return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", route_id, flags=re.IGNORECASE)) + + +def state_name(case: str, route_id: str) -> str: + """The state file's base name. Always route-qualified, so a case that covers several routes + (evm-hyperlane over eth/wbtc/usdt, aleo-hyperlane over eth/wbtc/usdt/sol) can never resume one + route's checkpoint under another's — veil only did this for aleo-hyperlane.""" + return f"{case}-{route_slug(route_id)}" + + +def asset_ref(asset: Asset) -> str: + return f"{asset.chain_id}/{asset.key}" + + +def default_amount(registry: Registry, case: str, route: Route) -> str: + """veil's literal for the xReserve cases, otherwise one atomic unit of the source asset.""" + spec = CASES[case] + return spec.amount or one_atomic_unit(registry.asset(route.source_asset_id).decimals) + + +def sender_for(bridge: Any, route: Route) -> str | None: + """The address that will sign the source leg, read from the configured connection.""" + family = bridge.registry.chain(bridge.registry.asset(route.source_asset_id).chain_id).family + if family == "aleo": + return bridge.aleo_address() + connection = bridge.ethereum if family == "evm" else bridge.solana + if connection is None: + raise LiveCaseError(f"No {family} connection is configured for {route.id}") + return connection.address + + +def default_recipient(bridge: Any, route: Route) -> str: + """Our own address on the destination chain (the operator overrides it through the config vars).""" + family = bridge.registry.chain(bridge.registry.asset(route.destination_asset_id).chain_id).family + if family == "aleo": + return bridge.aleo_address() + # Ruling: probe the CONNECTION, never the bridge.eth/bridge.sol properties — those raise. + connection = bridge.ethereum if family == "evm" else bridge.solana + if connection is None or connection.address is None: + raise LiveCaseError(f"No {family} address is configured to receive {route.id}") + return connection.address + + +# ── printing (addresses and amounts only, never secrets) ────────────────────── + +def _human(bridge: Any, atomic: int, asset_id: str) -> str: + return f"{format_decimal_amount(atomic, bridge.registry.asset(asset_id).decimals)} " \ + f"{bridge.registry.asset(asset_id).symbol}" + + +def print_quote(bridge: Any, quote: Any, *, case: str, route_id: str, log: Callable[[str], None] = print) -> None: + """veil's ``console.table``: what is about to move, where, and what it costs.""" + plan = quote.plan + source, destination = bridge.registry.asset(plan.source_asset_id), bridge.registry.asset(plan.destination_asset_id) + log(f"\n== {case} [{plan.environment}] {route_id}") + log(f" kind {quote.kind} (registry {plan.registry_version})") + log(f" amount {plan.amount} {source.symbol} → {quote.amount_out or plan.amount} {destination.symbol}") + log(f" sender {plan.sender}") + log(f" recipient {plan.recipient}") + log(f" mint mode {plan.mint_mode}") + for fee in quote.fees: + estimated = " (estimated)" if fee.estimated else "" + log(f" fee {fee.amount} {bridge.registry.asset(fee.asset_id).symbol} [{fee.kind}]{estimated}") + # An EvmXReserveQuote carries no `fees`: its protocol cost is the max fee the deposit authorizes. + max_fee = getattr(quote, "max_fee_atomic", None) + if max_fee is not None: + log(f" fee {_human(bridge, max_fee, source.id)} [xReserve max fee]") + for name in ("native_value_atomic", "native_fee_atomic", "approval_required", "total_lamports", + "igp_lamports", "rent_lamports", "payment_microcredits", "balance_atomic", + "allowance_atomic", "withdrawal_fee_atomic", "max_fee_atomic"): + if hasattr(quote, name): + log(f" {name:<24}{getattr(quote, name)}") + steps = " → ".join(f"{step.id}{'*' if step.irreversible else ''}" for step in plan.steps) + log(f" steps {steps} (* irreversible)") + + +# ── balances ────────────────────────────────────────────────────────────────── + +def read_balances(bridge: Any) -> dict[str, int]: + """``asset_id → atomic`` across every configured chain, or ``{}`` when a chain cannot be read. + + ``Bridge.status()`` already walks exactly the configured connections, so an Aleo-only client + simply reports no EVM/Solana rows instead of raising. + """ + try: + return {asset_id: atomic for chain in bridge.status().chains for asset_id, atomic in chain.balances.items()} + except Exception: # noqa: BLE001 — a precheck must never fail the case + return {} + + +def _require(balances: dict[str, int], asset_id: str, needed: int, *, what: str = "balance", + log: Callable[[str], None] = print) -> None: + have = balances.get(asset_id) + if have is None: + log(f" {what:<10} {asset_id}: unreadable — proceeding; the chain will enforce it") + return + log(f" {what:<10} {asset_id}: {have} atomic (need {needed})") + if have < needed: + raise Underfunded(asset_id=asset_id, needed=needed, have=have, what=what) + + +def _native_asset_id(bridge: Any, chain_id: str) -> str | None: + native = [a for a in bridge.registry.assets(chain=chain_id) if a.kind == "native"] + return native[0].id if native else None + + +def precheck(bridge: Any, quote: Any, *, case: str, log: Callable[[str], None] = print) -> dict[str, int]: + """Refuse to spend what the wallet does not have; raises :class:`Underfunded` with the shortfall.""" + plan = quote.plan + balances = read_balances(bridge) + source = bridge.registry.asset(plan.source_asset_id) + native_id = _native_asset_id(bridge, source.chain_id) + + if quote.kind == "evm-hyperlane": + if source.kind == "native": + _require(balances, source.id, quote.native_value_atomic, log=log) + else: + _require(balances, source.id, plan.amount_atomic, log=log) + if native_id: + _require(balances, native_id, quote.native_fee_atomic, what="gas", log=log) + elif quote.kind == "solana-hyperlane": + if native_id: + _require(balances, native_id, quote.total_lamports, log=log) + elif quote.kind == "aleo-hyperlane": + _require(balances, source.id, plan.amount_atomic, log=log) + if native_id: + _require(balances, native_id, quote.payment_microcredits, what="hook fee", log=log) + elif quote.kind == "evm-xreserve": + # The quote already read the wallet's USDC balance and allowance on chain. + if quote.balance_atomic < plan.amount_atomic: + raise Underfunded(asset_id=source.id, needed=plan.amount_atomic, have=quote.balance_atomic) + log(f" balance {source.id}: {quote.balance_atomic} atomic (need {plan.amount_atomic})") + elif quote.kind == "aleo-xreserve": + pass # the private record is selected (and checked) right before the burn + return balances + + +# ── the shared engine ───────────────────────────────────────────────────────── + +def _saver(state: LiveState, state_path: Path, benchmark: LiveBenchmark) -> Callable[[Any], None]: + def save(checkpoint: Any) -> None: + state.checkpoint = checkpoint.to_dict() if hasattr(checkpoint, "to_dict") else dict(checkpoint) + source = state.checkpoint.get("source") or {} + state.source_tx_id = source.get("transactionId") or state.source_tx_id + destination = state.checkpoint.get("destination") or {} + state.destination_tx_id = destination.get("transactionId") or state.destination_tx_id + save_live_state(state_path, state) + benchmark.mark("checkpoint-saved") + return save + + +def _assert_completed(state: LiveState, case: str) -> LiveState: + """Re-running a finished case is a no-op that re-asserts what was recorded (veil parity §4).""" + if not state.source_tx_id: + raise LiveCaseError(f"{case} state claims completion without a source transaction id") + return state + + +def _recover_from_state(bridge: Any, state: LiveState, *, benchmark: LiveBenchmark, + log: Callable[[str], None]) -> Any: + """Rebuild progress from the checkpoint ON DISK, dropping whatever ``execute`` returned. + + The user's instruction requires the recovery verbs proven for real: the checkpoint the store + holds — found through ``bridge.pending()`` when a store is bound — is the only input here. + """ + if state.checkpoint is None: + raise LiveCaseError("No checkpoint was saved for this transfer; nothing to recover from") + receipt_id = state.checkpoint.get("receiptId") + for progress in (bridge.pending() or []): + if receipt_id and progress.receipt.id == receipt_id: + log(f" recovered {receipt_id} from the bound checkpoint store ({len(bridge.pending())} pending)") + break + progress = bridge.recover(state.checkpoint) + benchmark.mark("source-recovered") + return progress + + +def _drive(bridge: Any, progress: Any, state: LiveState, state_path: Path, *, spec: CaseSpec, + secret_nonce: str | None, benchmark: LiveBenchmark, save: Callable[[Any], None], + wait_timeout_seconds: float, wait_poll_seconds: float, + log: Callable[[str], None]) -> Any: + """``wait`` / ``resume`` / ``complete`` until the transfer is done. Never calls ``execute``.""" + for _ in range(_MAX_TRANSITIONS): + if progress.next == "wait": + progress = bridge.wait(progress, timeout_seconds=wait_timeout_seconds, + poll_seconds=wait_poll_seconds, + on_error=lambda exc: log(f" transient {type(exc).__name__}: {exc}")) + benchmark.mark("wait-returned") + continue + if progress.next == "resume": + progress = bridge.resume(progress, on_checkpoint=save, secret_nonce=secret_nonce) + benchmark.mark("resume-returned") + continue + if progress.next == "complete": + if not secret_nonce: + raise LiveCaseError( + "The private mint needs the secret nonce kept beside this case's state file; " + "it is missing, so the mint cannot be completed here") + progress = bridge.complete(progress, secret_nonce=secret_nonce, on_checkpoint=save) + benchmark.mark("complete-returned") + continue + if progress.next == "failed": + raise LiveCaseError(f"{spec.name} failed: {progress.error}") + if progress.next == "done": + return progress + raise LiveCaseError(f"Unexpected progress state {progress.next!r} for {spec.name}") + raise LiveCaseError(f"{spec.name} did not settle after {_MAX_TRANSITIONS} lifecycle transitions") + + +def _select_private_record(bridge: Any, route: Route, amount_atomic: int, + log: Callable[[str], None]) -> str: + """The USDCx record the burn will spend, logged by amount and digest — never by plaintext.""" + program = route.meta_str("remoteToken") + try: + record = bridge.privacy.select_record(program, amount_atomic) + except InsufficientBalanceError as exc: + raise Underfunded(asset_id=bridge.registry.asset(route.source_asset_id).id, + needed=amount_atomic, have=0, what="private record") from exc + log(f" record {program} {redacted([record])} amount={record_amount(record)}") + return record + + +def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, recipient: str | None = None, + amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, + wait_timeout_seconds: float = WAIT_TIMEOUT_SECONDS, wait_poll_seconds: float = WAIT_POLL_SECONDS, + log: Callable[[str], None] = print) -> LiveState: + """Run one case over one route, resuming from ``state_path``. See the module docstring for the flow.""" + spec = CASES[case] + state_path = Path(state_path) + benchmark = benchmark if benchmark is not None else LiveBenchmark(case, log=log) + state = load_live_state(state_path, route_id) + if state.completed: + log(f" {case} {route_id} is already complete (source {state.source_tx_id})") + return _assert_completed(state, case) + + route = bridge.registry.route(route_id) + if not route.active: + raise LiveCaseError(f"Route {route_id} is {route.availability}; the registry will not execute it") + if route.protocol != spec.protocol: + raise LiveCaseError(f"Route {route_id} is {route.protocol}, not a {spec.protocol} case") + source, destination = bridge.registry.asset(route.source_asset_id), bridge.registry.asset(route.destination_asset_id) + amount = amount or default_amount(bridge.registry, case, route) + recipient = recipient or default_recipient(bridge, route) + sender = sender_for(bridge, route) + + # The private-mint nonce is created only when we are actually going to deposit: a quote-only + # rehearsal leaves no secret file behind. An existing one is always reused. + secret_nonce = None + if spec.private_mint: + secret_nonce = ensure_secret_nonce(state_path) if execute else load_secret_nonce(state_path) + state.secret_nonce_present = secret_nonce is not None + + progress = None + if state.checkpoint is None: + quote = bridge.quote(asset_ref(source), asset_ref(destination), amount=amount, recipient=recipient, + sender=sender, protocol=route.protocol, mint_mode=spec.mint_mode, + secret_nonce=secret_nonce or "0scalar") + benchmark.mark("quote-returned") + print_quote(bridge, quote, case=case, route_id=route_id, log=log) + balances = precheck(bridge, quote, case=case, log=log) + before = balances.get(destination.id) + if before is not None: + state.destination_balance_before = str(before) + if not execute: + log(f"\n quote only — nothing was submitted for {route_id}.") + return state + save_live_state(state_path, state) + + record = (_select_private_record(bridge, route, quote.plan.amount_atomic, log) + if case == "aleo-xreserve" else None) + save = _saver(state, state_path, benchmark) + gas = getattr(quote, "payment_microcredits", None) if spec.protocol == "hyperlane" else None + progress = bridge.execute(quote.plan, on_checkpoint=save, mode=spec.mode, record=record, + secret_nonce=secret_nonce, gas_payment_microcredits=gas) + benchmark.mark("execute-returned") + state.source_tx_id = progress.receipt.source_tx_id or state.source_tx_id + save_live_state(state_path, state) + if progress.receipt.protocol_state.get("blockhashExpired") is True: + raise LiveCaseError( + f"Solana source transaction {state.source_tx_id} expired; inspect it on chain before " + "clearing the checkpoint — never re-run execute") + else: + log(f" resuming {case} {route_id} from the saved checkpoint") + + save = _saver(state, state_path, benchmark) + if not execute: + # A saved checkpoint plus no acknowledgement: report what is pending, submit nothing. + log(f"\n quote only — {route_id} has a saved checkpoint; re-run with the acknowledgement to finish it.") + return state + + progress = _recover_from_state(bridge, state, benchmark=benchmark, log=log) + progress = _drive(bridge, progress, state, state_path, spec=spec, secret_nonce=secret_nonce, + benchmark=benchmark, save=save, wait_timeout_seconds=wait_timeout_seconds, + wait_poll_seconds=wait_poll_seconds, log=log) + benchmark.mark("destination-delivered") + + receipt = progress.receipt + state.source_tx_id = receipt.source_tx_id or state.source_tx_id + state.message_id = receipt.protocol_state.get("messageId") or state.message_id + state.destination_tx_id = receipt.destination_tx_id or state.destination_tx_id + if spec.protocol == "hyperlane" and state.destination_tx_id is None and state.source_tx_id: + delivery = wait_for_hyperlane_delivery(state.source_tx_id, timeout_seconds=300, poll_seconds=15) + if delivery is not None: + state.message_id = state.message_id or delivery.message_id + state.destination_tx_id = delivery.destination_tx_id + else: + log(" explorer unavailable; the destination transaction id was not recorded") + state.completed = True + save_live_state(state_path, state) + log(f" done source={state.source_tx_id} message={state.message_id} " + f"destination={state.destination_tx_id}") + log(f" {benchmark.summary()}") + return state + + +def _runner(case: str) -> Callable[..., LiveState]: + def run(bridge: Any, route_id: str, *, state_path: Path | str, recipient: str | None = None, + amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, + **kwargs: Any) -> LiveState: + return run_case(bridge, case, route_id, state_path=state_path, recipient=recipient, amount=amount, + execute=execute, benchmark=benchmark, **kwargs) + run.__name__ = f"run_{case.replace('-', '_')}" + run.__doc__ = (f"veil {CASES[case].veil_source}: {case} over one route " + f"(amount {CASES[case].amount or 'one atomic unit'}" + f"{', private mint' if CASES[case].private_mint else ''}" + f"{f', execute mode {CASES[case].mode}' if CASES[case].mode else ''}).") + return run + + +run_evm_hyperlane = _runner("evm-hyperlane") +run_evm_xreserve = _runner("evm-xreserve") +run_aleo_hyperlane = _runner("aleo-hyperlane") +run_aleo_xreserve = _runner("aleo-xreserve") +run_solana_hyperlane = _runner("solana-hyperlane") + +RUNNERS: dict[str, Callable[..., LiveState]] = { + "evm-hyperlane": run_evm_hyperlane, + "evm-xreserve": run_evm_xreserve, + "aleo-hyperlane": run_aleo_hyperlane, + "aleo-xreserve": run_aleo_xreserve, + "solana-hyperlane": run_solana_hyperlane, +} + +__all__ = [ + "CASES", "CASE_NAMES", "CASE_TIMEOUT_SECONDS", "CaseSpec", "LiveCaseError", "RUNNERS", "Underfunded", + "asset_ref", "case_for_route", "default_amount", "default_recipient", "precheck", "print_quote", + "read_balances", "route_slug", "routes_for_case", "run_aleo_hyperlane", "run_aleo_xreserve", + "run_case", "run_evm_hyperlane", "run_evm_xreserve", "run_solana_hyperlane", "sender_for", + "state_name", +] diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py index d9c0614d..597079c1 100644 --- a/bridge-sdk/tests/test_live_helpers.py +++ b/bridge-sdk/tests/test_live_helpers.py @@ -405,3 +405,447 @@ def test_underfunded_carries_the_shortfall(): error = live_helpers.Underfunded(asset_id="ethereum/usdc", needed=2_000_000, have=1_500_000) assert error.shortfall == 500_000 assert "ethereum/usdc" in str(error) and "500000" in str(error) + + +# ══ cases.py ══════════════════════════════════════════════════════════════════ + +from pathlib import Path # noqa: E402 + +from aleo_bridge import lifecycle # noqa: E402 +from aleo_bridge.registry import DEFAULT_REGISTRY # noqa: E402 +from aleo_bridge.types import Progress, Receipt, Status # noqa: E402 +from tests.fakes.fake_bridge import ALEO_RECIPIENT, EVM_ADDRESS, FakeBridge # noqa: E402 +from tests.live import cases as live_cases # noqa: E402 + +ETH_ROUTE = "hyperlane:ethereum/eth->aleo/eth" +USDC_ROUTE = "xreserve:ethereum/usdc->aleo/usdcx" + + +class LiveFakeBridge(FakeBridge): + """``FakeBridge`` plus the public lifecycle verbs — the surface ``cases.py`` is allowed to use. + + The real ``Bridge`` methods are thin wrappers over ``lifecycle``; wiring the same functions onto + the fake keeps the harness honest (it may only call verbs that exist) without a second fake. + """ + + def quote(self, source, destination, **kwargs): + return lifecycle.quote(self, source=source, destination=destination, **kwargs) + + def execute(self, plan, **kwargs): + return lifecycle.execute(self, plan, **kwargs) + + def wait(self, progress, **kwargs): + return lifecycle.wait(self, progress, **kwargs) + + def recover(self, checkpoint): + return lifecycle.recover(self, checkpoint) + + def resume(self, progress, **kwargs): + return lifecycle.resume(self, progress, **kwargs) + + def complete(self, progress, **kwargs): + return lifecycle.complete(self, progress, **kwargs) + + def pending(self): + if self.checkpoints is None: + return [] + return [lifecycle.progress_from_checkpoint(self.registry, cp) for cp in self.checkpoints.list()] + + +@pytest.fixture +def fake(): + return LiveFakeBridge() + + +def test_cases_are_veils_five_mainnet_cases_with_their_literals(): + assert live_cases.CASE_NAMES == live_config.CASE_NAMES + assert live_cases.CASES["evm-xreserve"].amount == "2" # veil evm-xreserve:57 + assert live_cases.CASES["evm-xreserve"].mint_mode == "private" # veil evm-xreserve:60 + assert live_cases.CASES["aleo-xreserve"].amount == "2.000001" # veil aleo-xreserve:97 + assert live_cases.CASES["aleo-xreserve"].mode == "private" + assert live_cases.CASES["aleo-hyperlane"].mode == "signer" # veil aleo-hyperlane:135 + assert live_cases.CASES["evm-hyperlane"].amount is None # one atomic unit + assert all(spec.veil_source for spec in live_cases.CASES.values()) + + +def test_every_mainnet_route_is_covered_by_exactly_one_case(): + """'All the routes back and forth': no mainnet route may be left without a case.""" + routes = DEFAULT_REGISTRY.routes(environment="mainnet") + covered = {route.id: live_cases.case_for_route(DEFAULT_REGISTRY, route) for route in routes} + assert all(case is not None for case in covered.values()), \ + [rid for rid, case in covered.items() if case is None] + + by_case = {case: {route.id for route in live_cases.routes_for_case(DEFAULT_REGISTRY, case)} + for case in live_cases.CASE_NAMES} + assert set().union(*by_case.values()) == set(covered) + for left in live_cases.CASE_NAMES: + for right in live_cases.CASE_NAMES: + if left != right: + assert not by_case[left] & by_case[right] + + active = {route.id for route in routes if route.active} + assert ETH_ROUTE in by_case["evm-hyperlane"] and "hyperlane:ethereum/wbtc->aleo/wbtc" in by_case["evm-hyperlane"] + assert "hyperlane:aleo/sol->solana/sol" in by_case["aleo-hyperlane"] + assert by_case["solana-hyperlane"] & active == {"hyperlane:solana/sol->aleo/sol"} + assert by_case["evm-xreserve"] & active == {USDC_ROUTE} + assert by_case["aleo-xreserve"] & active == {"xreserve:aleo/usdcx->ethereum/usdc"} + + +def test_default_amount_is_one_atomic_unit_or_veils_literal(): + route = DEFAULT_REGISTRY.route(ETH_ROUTE) + assert live_cases.default_amount(DEFAULT_REGISTRY, "evm-hyperlane", route) == "0.000000000000000001" + usdc = DEFAULT_REGISTRY.route(USDC_ROUTE) + assert live_cases.default_amount(DEFAULT_REGISTRY, "evm-xreserve", usdc) == "2" + + +def test_state_names_are_route_qualified(): + assert live_cases.state_name("evm-hyperlane", ETH_ROUTE) == "evm-hyperlane-hyperlane-ethereum-eth-aleo-eth" + assert live_cases.state_name("evm-hyperlane", "hyperlane:ethereum/wbtc->aleo/wbtc") \ + != live_cases.state_name("evm-hyperlane", ETH_ROUTE) + + +def test_quote_only_prints_the_table_and_submits_nothing(fake, tmp_path): + lines: list[str] = [] + state_path = tmp_path / "evm-hyperlane.json" + state = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, + execute=False, log=lines.append) + + assert state.completed is False and state.checkpoint is None + assert not state_path.exists() # a rehearsal leaves no state behind + assert not any(event[0] in {"evm_send", "submit", "prove"} for event in fake.events) + printed = "\n".join(lines) + assert ETH_ROUTE in printed and "evm-hyperlane" in printed and "quote only" in printed + + +def test_quote_only_never_creates_the_private_mint_secret(fake, tmp_path): + state_path = tmp_path / "evm-xreserve.json" + live_cases.run_case(fake, "evm-xreserve", USDC_ROUTE, state_path=state_path, execute=False, + log=lambda _: None) + assert not live_helpers.secret_path(state_path).exists() + + +def test_underfunded_names_the_asset_and_the_shortfall(fake, tmp_path): + fake.eth.balances = {"ethereum/eth": 5} + with pytest.raises(live_helpers.Underfunded) as excinfo: + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=tmp_path / "s.json", + execute=False, log=lambda _: None) + assert excinfo.value.asset_id == "ethereum/eth" and excinfo.value.shortfall > 0 + + +def test_a_completed_case_is_a_no_op_that_re_asserts_its_record(fake, tmp_path): + state_path = tmp_path / "done.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState( + route_id=ETH_ROUTE, source_tx_id="0xsource", message_id="0xmessage", + destination_tx_id="at1destination", completed=True)) + + state = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, + execute=True, log=lambda _: None) + assert state.completed and state.source_tx_id == "0xsource" + assert fake.calls == [] and fake.events == [] # nothing was quoted, nothing was submitted + + +def test_a_completed_state_without_a_source_transaction_fails_closed(fake, tmp_path): + state_path = tmp_path / "bad.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState(route_id=ETH_ROUTE, completed=True)) + with pytest.raises(live_helpers.LiveCaseError): + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + log=lambda _: None) + + +def test_an_inactive_route_is_refused_before_anything_is_read(fake, tmp_path): + with pytest.raises(live_helpers.LiveCaseError, match="metadata-required"): + live_cases.run_case(fake, "evm-hyperlane", "hyperlane:ethereum/usad->aleo/usad", + state_path=tmp_path / "s.json", execute=False, log=lambda _: None) + + +def test_recipient_and_sender_default_to_our_own_addresses(fake): + route = DEFAULT_REGISTRY.route(ETH_ROUTE) + assert live_cases.default_recipient(fake, route) == ALEO_RECIPIENT + assert live_cases.sender_for(fake, route) == EVM_ADDRESS + + outbound = DEFAULT_REGISTRY.route("hyperlane:aleo/eth->ethereum/eth") + assert live_cases.default_recipient(fake, outbound) == EVM_ADDRESS + assert live_cases.sender_for(fake, outbound) == ALEO_RECIPIENT + + +def test_an_unconfigured_destination_chain_is_reported_not_an_attribute_error(): + """Ruling: probe ``bridge.solana``/``bridge.ethereum``; ``bridge.sol``/``bridge.eth`` RAISE.""" + aleo_only = LiveFakeBridge(ethereum=False, solana=False) + assert aleo_only.ethereum is None and aleo_only.solana is None + route = DEFAULT_REGISTRY.route("hyperlane:aleo/sol->solana/sol") + with pytest.raises(live_helpers.LiveCaseError, match="solana"): + live_cases.default_recipient(aleo_only, route) + aleo_only.public_balances = {"aleo/sol": 7} + assert live_cases.read_balances(aleo_only) == {"aleo/sol": 7} # the Aleo row still reads + + +def _xreserve_quote(**extra): + from aleo_bridge.types import EvmXReserveQuote + + plan = lifecycle.prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="2", recipient=ALEO_RECIPIENT, mint_mode="private") + return EvmXReserveQuote(kind="evm-xreserve", plan=plan, fees=(), amount_out="2", hook_data=b"", + remote_recipient_bytes32=b"\x00" * 32, balance_atomic=5_000_000, + allowance_atomic=0, approval_required=True, **extra) + + +def test_print_quote_renders_the_xreserve_max_fee_as_the_protocol_fee_line(fake): + """``EvmXReserveQuote.fees`` is empty: its max fee is the protocol cost a human has to see.""" + lines: list[str] = [] + live_cases.print_quote(fake, _xreserve_quote(max_fee_atomic=100_000), + case="evm-xreserve", route_id=USDC_ROUTE, log=lines.append) + printed = "\n".join(lines) + assert "0.1 USDC [xReserve max fee]" in printed + assert "max_fee_atomic" in printed and ALEO_RECIPIENT in printed + assert "scalar" not in printed + + +# ── the drive loop: wait → resume → complete → done, and never execute twice ── + +class _ScriptedBridge: + """A bridge whose lifecycle verbs return a scripted sequence, to test ``_drive`` in isolation.""" + + def __init__(self, steps): + self.steps, self.calls = list(steps), [] + self.plan = lifecycle.prepare(DEFAULT_REGISTRY, source="ethereum/usdc", destination="aleo/usdcx", + amount="2", recipient=ALEO_RECIPIENT, mint_mode="private") + + def _next(self, verb): + self.calls.append(verb) + state = self.steps.pop(0) + receipt = Receipt(id="r1", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + source_tx_id="0xsource", + protocol_state={"routeId": USDC_ROUTE, "messageId": "0xmessage"}) + return Progress(state, self.plan, receipt, error="scripted failure" if state == "failed" else None) + + def wait(self, progress, **kwargs): + return self._next("wait") + + def resume(self, progress, **kwargs): + return self._next("resume") + + def complete(self, progress, **kwargs): + return self._next("complete") + + def execute(self, *args, **kwargs): + raise AssertionError("execute must never be called from the drive loop") + + +def _drive(bridge, first, **kwargs): + benchmark = live_helpers.LiveBenchmark("t", log=lambda _: None) + progress = Progress(first, bridge.plan, + Receipt(id="r1", protocol="xreserve", status=Status.SOURCE_CONFIRMING, + protocol_state={"routeId": USDC_ROUTE})) + return live_cases._drive(bridge, progress, live_helpers.LiveState(route_id=USDC_ROUTE), + Path("/nonexistent/state.json"), spec=live_cases.CASES["evm-xreserve"], + benchmark=benchmark, save=lambda _: None, wait_timeout_seconds=1, + wait_poll_seconds=0, log=lambda _: None, **kwargs) + + +def test_drive_runs_wait_resume_complete_until_done(): + bridge = _ScriptedBridge(["resume", "wait", "complete", "wait", "done"]) + progress = _drive(bridge, "wait", secret_nonce="7scalar") + assert progress.next == "done" + assert bridge.calls == ["wait", "resume", "wait", "complete", "wait"] + + +def test_drive_raises_the_reported_error_on_failure(): + bridge = _ScriptedBridge(["failed"]) + with pytest.raises(live_helpers.LiveCaseError, match="scripted failure"): + _drive(bridge, "wait", secret_nonce="7scalar") + + +def test_drive_refuses_a_private_mint_without_the_kept_nonce(): + bridge = _ScriptedBridge(["complete"]) + with pytest.raises(live_helpers.LiveCaseError, match="secret nonce"): + _drive(bridge, "wait", secret_nonce=None) + + +def test_drive_gives_up_rather_than_looping_forever(): + bridge = _ScriptedBridge(["wait"] * 40) + with pytest.raises(live_helpers.LiveCaseError, match="did not settle"): + _drive(bridge, "wait", secret_nonce="7scalar") + + +# ══ scripts/rehearse.py ═══════════════════════════════════════════════════════ + +import importlib.util # noqa: E402 + +from aleo_bridge.errors import PollingTimeoutError # noqa: E402 + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "rehearse.py" + + +@pytest.fixture(scope="module") +def rehearse(): + spec = importlib.util.spec_from_file_location("rehearse", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def state_dir(monkeypatch, tmp_path): + monkeypatch.setenv(STATE_DIR, str(tmp_path)) + return tmp_path + + +def _report(path): + return json.loads(Path(path).read_text()) + + +def test_cli_parses_the_documented_flags(rehearse): + args = rehearse.parse_args(["--case", "evm-hyperlane"]) + assert args.case == "evm-hyperlane" and args.route is None + assert args.quote_only is False and args.recover is None and args.report is None + + args = rehearse.parse_args(["--case", "aleo-xreserve", "--route", "xreserve:aleo/usdcx->ethereum/usdc", + "--quote-only", "--report", "/tmp/r.json"]) + assert args.route == "xreserve:aleo/usdcx->ethereum/usdc" and args.quote_only and args.report == "/tmp/r.json" + + args = rehearse.parse_args(["--recover", "/tmp/state.json"]) + assert args.recover == "/tmp/state.json" and args.case is None + + for bad in ([], ["--case", "not-a-case"]): + with pytest.raises(SystemExit): + rehearse.parse_args(bad) + + +def test_cli_has_no_reset_flag(rehearse): + """veil's solana-deposit.ts --reset deletes a checkpoint without checking the chain; not ported.""" + with pytest.raises(SystemExit): + rehearse.parse_args(["--case", "evm-hyperlane", "--reset"]) + + +def test_quote_only_run_reports_every_route_of_the_case(rehearse, fake, state_dir, tmp_path): + lines: list[str] = [] + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + + assert code == rehearse.EXIT_OK + payload = _report(report) + rows = {row["route_id"]: row for row in payload["results"]} + assert set(rows) == {route.id for route in live_cases.routes_for_case(DEFAULT_REGISTRY, "evm-hyperlane")} + assert rows[ETH_ROUTE]["status"] == "quote-only" + assert rows["hyperlane:ethereum/usad->aleo/usad"]["status"] == "skipped" + assert "metadata-required" in rows["hyperlane:ethereum/usad->aleo/usad"]["reason"] + assert payload["execute"] is False and payload["case"] == "evm-hyperlane" + + printed = "\n".join(lines) + assert ETH_ROUTE in printed and "quote-only" in printed + assert not any(event[0] in {"evm_send", "submit"} for event in fake.events) + + +def test_a_single_route_can_be_selected(rehearse, fake, state_dir, tmp_path): + report = tmp_path / "report.json" + rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + assert [row["route_id"] for row in _report(report)["results"]] == [ETH_ROUTE] + + +def test_without_the_acknowledgements_the_run_quotes_and_names_the_variable(rehearse, fake, state_dir, + monkeypatch, tmp_path): + """The gate is READ here; nothing prints a line that would set it in a subshell.""" + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-hyperlane") # the case is acknowledged, submission is not + lines: list[str] = [] + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + + payload = _report(report) + assert code == rehearse.EXIT_OK and payload["execute"] is False + assert "BRIDGE_LIVE_MAINNET_EXECUTE" in payload["reason"] + printed = "\n".join(lines) + assert "export " not in printed and "I_ACKNOWLEDGE" not in printed + assert "BRIDGE_LIVE_MAINNET_EXECUTE" in printed + + +def test_acknowledged_runs_ask_the_case_to_execute(rehearse, fake, state_dir, monkeypatch, tmp_path): + monkeypatch.setenv(FUNDS, "1") + monkeypatch.setenv(ACK, "I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS") + monkeypatch.setenv(CASES, "evm-hyperlane") + monkeypatch.setenv(EXECUTE, "I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS") + seen = {} + + def runner(bridge, route_id, **kwargs): + seen.update(route_id=route_id, execute=kwargs["execute"], state_path=kwargs["state_path"]) + return live_helpers.LiveState(route_id=route_id, source_tx_id="0xsource", message_id="0xm", + destination_tx_id="at1d", completed=True) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + + assert code == rehearse.EXIT_OK and seen["execute"] is True + assert Path(seen["state_path"]) == state_dir / "mainnet" / f"{live_cases.state_name('evm-hyperlane', ETH_ROUTE)}.json" + row = _report(report)["results"][0] + assert row["status"] == "completed" and row["source_tx_id"] == "0xsource" and row["message_id"] == "0xm" + + +def test_an_underfunded_case_is_skipped_with_its_shortfall(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise live_helpers.Underfunded(asset_id="ethereum/eth", needed=1000, have=1) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_OK and row["status"] == "skipped" and "999" in row["reason"] + + +def test_a_timeout_is_pending_with_the_resume_command_not_a_failure(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise PollingTimeoutError("still in flight", status=Status.DELIVERY_PENDING) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + lines: list[str] = [] + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lines.append) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_PENDING and row["status"] == "pending" + assert "--recover" in row["resume"] and row["resume"] in "\n".join(lines) + + +def test_a_failed_case_exits_one(rehearse, fake, state_dir, monkeypatch, tmp_path): + def runner(bridge, route_id, **kwargs): + raise live_helpers.LiveCaseError("the destination rejected it") + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + report = tmp_path / "report.json" + code = rehearse.run(["--case", "evm-hyperlane", "--route", ETH_ROUTE, "--quote-only", "--report", str(report)], + bridge_factory=lambda: fake, log=lambda _: None) + row = _report(report)["results"][0] + assert code == rehearse.EXIT_FAILED and row["status"] == "failed" and "rejected" in row["reason"] + + +def test_recover_resolves_the_case_from_the_state_file(rehearse, fake, state_dir, monkeypatch, tmp_path): + state_path = state_dir / "mainnet" / "evm-hyperlane-resume.json" + live_helpers.save_live_state(state_path, live_helpers.LiveState(route_id=ETH_ROUTE, source_tx_id="0xs")) + seen = {} + + def runner(bridge, route_id, **kwargs): + seen.update(route_id=route_id, state_path=kwargs["state_path"]) + return live_helpers.LiveState(route_id=route_id, source_tx_id="0xs", completed=True) + + monkeypatch.setitem(live_cases.RUNNERS, "evm-hyperlane", runner) + code = rehearse.run(["--recover", str(state_path), "--quote-only"], + bridge_factory=lambda: fake, log=lambda _: None) + assert code == rehearse.EXIT_OK + assert seen["route_id"] == ETH_ROUTE and Path(seen["state_path"]) == state_path + + +def test_the_table_renders_one_line_per_route(rehearse): + rows = [{"case": "evm-hyperlane", "route_id": ETH_ROUTE, "status": "quote-only", "reason": "", + "source_tx_id": None, "message_id": None, "destination_tx_id": None, "resume": ""}, + {"case": "evm-hyperlane", "route_id": "hyperlane:ethereum/wbtc->aleo/wbtc", "status": "skipped", + "reason": "registry availability: metadata-required", "source_tx_id": None, + "message_id": None, "destination_tx_id": None, "resume": ""}] + table = rehearse.render_table(rows) + assert ETH_ROUTE in table and "quote-only" in table and "metadata-required" in table + assert len(table.strip().splitlines()) >= 3 # header + two rows From 681a107590dcd95578314a2d1eb762667dacf61a Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 15:01:58 -0400 Subject: [PATCH 89/94] docs(bridge-sdk): README section for the live tests 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. --- bridge-sdk/README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 1c4704de..5b68fda6 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -119,3 +119,64 @@ Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo BRIDGE_LIVE_READS=1 BRIDGE_LIVE_SIMULATE=1 .venv/bin/python -m pytest -m live tests/live -q Literals and vectors: `docs/veil-brief.md`. + +## Live tests + +The funded suite (`tests/live/`, ported from veil's `test/integration/live/`) moves real money. It +is off unless you turn it on, in your own shell, one command at a time. + +**Gates** (read only — nothing in this repository sets them; the exact acknowledgement strings are +the constants in `tests/live/config.py`): + +| Variable | Effect | +| --- | --- | +| `BRIDGE_LIVE_FUNDS=1` + `BRIDGE_LIVE_STATE_DIR=` | funded cases exist at all | +| `BRIDGE_LIVE_MAINNET_ACK=…` + `BRIDGE_LIVE_MAINNET_CASES=` | the named mainnet cases may run | +| `BRIDGE_LIVE_MAINNET_EXECUTE=…` | the wallet may actually submit | + +Without the last one every case runs to the quote, prints the route/amount/fee table and returns — +that is the default, and it is how you rehearse. Keys and endpoints come from `Bridge.from_env()` +(`BRIDGE_PRIVATE_KEY`, `EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`/ +`BRIDGE_SOLANA_PRIVATE_KEY`, `ETHEREUM_RPC_URL`/`BRIDGE_LIVE_ETHEREUM_RPC_URL`, +`SOLANA_RPC_URL`/`BRIDGE_LIVE_SOLANA_RPC_URL`); recipients default to your own addresses and can be +overridden with `BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT` / `BRIDGE_LIVE_EVM_RECIPIENT` / +`BRIDGE_LIVE_SOLANA_RECIPIENT`. + +**State.** Each case keeps one file at `$BRIDGE_LIVE_STATE_DIR//-.json` +(mode 600) holding the checkpoint, the source/destination transaction ids and the message id; the +private-mint secret nonce lives beside it in `.secret` (mode 600, created exclusively) and +never in the state, a log or a checkpoint. Re-running a case resumes from that file — recover +first, then `wait`/`resume`/`complete`; a completed case re-asserts what it recorded and exits. +A timeout is *pending*, not a failure: the checkpoint stays on disk and the run prints the +`--recover` command. + +**Cases and routes.** Five cases, each parametrized over every registry route it covers — both +directions are separate cases, and a route with `availability != "active"` is reported as +skipped-by-registry rather than dropped: + +| Case | Routes | Amount | +| --- | --- | --- | +| `evm-hyperlane` | ethereum → aleo (ETH, WBTC, USDT) | one atomic unit | +| `aleo-hyperlane` | aleo → ethereum (ETH, WBTC, USDT), aleo → solana (SOL) | one atomic unit, `mode="signer"` | +| `solana-hyperlane` | solana → aleo (SOL) | 1 lamport | +| `evm-xreserve` | ethereum USDC → aleo USDCx | `2` USDC, private mint (needs `complete`) | +| `aleo-xreserve` | aleo USDCx → ethereum USDC | `2.000001` USDCx private burn, delivers 1 atomic unit | + +**Funding per run** (from the 2026-09-17 read-only mainnet quote sweep): ETH/WBTC/USDT Hyperlane +deposits cost ≈0.0000838 ETH each in native Hyperlane fees plus L1 gas (USDT also needs one +approval); the Aleo-origin legs cost 8.174147 (ETH), 9.138947 (WBTC), 9.138947 (USDT) and 7.661056 +(SOL) credits in IGP payment plus the Aleo transaction fee, and need the asset's public balance on +Aleo; solana → aleo costs 5,647,521 lamports all-in; the xReserve deposit needs 2 USDC plus gas, +and the burn needs an unspent private USDCx record of at least 2.000001. Return legs spend what +the matching inbound leg minted, so run inbound first — an unfunded return leg skips with its +shortfall printed rather than failing. + +**Running it.** + + python scripts/rehearse.py --case evm-hyperlane --quote-only # price every route, submit nothing + python scripts/rehearse.py --case evm-xreserve --report run.json # submits only if acknowledged + python scripts/rehearse.py --recover "$BRIDGE_LIVE_STATE_DIR/mainnet/-.json" + +Exit codes: `0` ok, `1` a case failed, `2` something is still pending. The same case functions back +the pytest suite (`-m live`), so the CLI and the tests cannot drift. The harness itself is covered +hermetically by `tests/test_live_helpers.py`. From ef6c270d69776bd24acc928270733a77bd207440 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 15:03:52 -0400 Subject: [PATCH 90/94] =?UTF-8?q?fix(bridge-sdk):=20veil=20parity=20for=20?= =?UTF-8?q?the=20live=20cases=20=E2=80=94=20source=20confirmation,=20marks?= =?UTF-8?q?,=20balance=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/scripts/rehearse.py | 5 ++++- bridge-sdk/tests/live/cases.py | 19 ++++++++++++++----- bridge-sdk/tests/test_live_helpers.py | 7 +++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/bridge-sdk/scripts/rehearse.py b/bridge-sdk/scripts/rehearse.py index 5efb224b..269aba2f 100644 --- a/bridge-sdk/scripts/rehearse.py +++ b/bridge-sdk/scripts/rehearse.py @@ -175,7 +175,9 @@ def _default_bridge() -> Any: def run(argv: list[str] | None = None, *, bridge_factory: Callable[[], Any] = _default_bridge, log: Callable[[str], None] = print) -> int: args = parse_args(argv) + run_benchmark = LiveBenchmark("rehearse", log=log) bridge = bridge_factory() + run_benchmark.mark("clients-created") environment = bridge.environment if args.recover: @@ -213,7 +215,8 @@ def run(argv: list[str] | None = None, *, bridge_factory: Callable[[], Any] = _d payload = {"generated_at": datetime.now(timezone.utc).isoformat(), "case": case, "environment": environment, "registry_version": bridge.registry.version, - "execute": execute, "reason": reason, "results": rows} + "execute": execute, "reason": reason, "results": rows, + "run_benchmark": run_benchmark.as_dict()} if args.report: report = Path(args.report).expanduser() report.parent.mkdir(parents=True, exist_ok=True) diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py index f7ea8f4e..b780351c 100644 --- a/bridge-sdk/tests/live/cases.py +++ b/bridge-sdk/tests/live/cases.py @@ -35,7 +35,7 @@ from .config import one_atomic_unit from .helpers import (LiveBenchmark, LiveCaseError, LiveState, Underfunded, ensure_secret_nonce, load_live_state, load_secret_nonce, redacted, save_live_state, - wait_for_hyperlane_delivery) + wait_for_aleo_transaction, wait_for_hyperlane_delivery) #: veil's per-test budget (`30 * 60_000`). CASE_TIMEOUT_SECONDS = 30 * 60.0 @@ -276,10 +276,9 @@ def _recover_from_state(bridge: Any, state: LiveState, *, benchmark: LiveBenchma if state.checkpoint is None: raise LiveCaseError("No checkpoint was saved for this transfer; nothing to recover from") receipt_id = state.checkpoint.get("receiptId") - for progress in (bridge.pending() or []): - if receipt_id and progress.receipt.id == receipt_id: - log(f" recovered {receipt_id} from the bound checkpoint store ({len(bridge.pending())} pending)") - break + pending = bridge.pending() or [] + if receipt_id and any(entry.receipt.id == receipt_id for entry in pending): + log(f" pending {receipt_id} is in the bound checkpoint store ({len(pending)} in flight)") progress = bridge.recover(state.checkpoint) benchmark.mark("source-recovered") return progress @@ -352,6 +351,7 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r amount = amount or default_amount(bridge.registry, case, route) recipient = recipient or default_recipient(bridge, route) sender = sender_for(bridge, route) + benchmark.mark("plan-prepared") # The private-mint nonce is created only when we are actually going to deposit: a quote-only # rehearsal leaves no secret file behind. An existing one is always reused. @@ -389,6 +389,11 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r raise LiveCaseError( f"Solana source transaction {state.source_tx_id} expired; inspect it on chain before " "clearing the checkpoint — never re-run execute") + if spec.source_family == "aleo" and state.source_tx_id: + # veil aleo-hyperlane:153 / aleo-xreserve:136: confirm the source on chain before + # recovering, so a rejected execution is reported as itself rather than as a timeout. + wait_for_aleo_transaction(bridge, state.source_tx_id) + benchmark.mark("source-confirmed") else: log(f" resuming {case} {route_id} from the saved checkpoint") @@ -417,6 +422,10 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r log(" explorer unavailable; the destination transaction id was not recorded") state.completed = True save_live_state(state_path, state) + after = read_balances(bridge).get(destination.id) + if after is not None and state.destination_balance_before is not None: + log(f" delivered {destination.id} +{after - int(state.destination_balance_before)} atomic " + f"(before {state.destination_balance_before}, after {after})") log(f" done source={state.source_tx_id} message={state.message_id} " f"destination={state.destination_tx_id}") log(f" {benchmark.summary()}") diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py index 597079c1..a09d97f8 100644 --- a/bridge-sdk/tests/test_live_helpers.py +++ b/bridge-sdk/tests/test_live_helpers.py @@ -517,6 +517,13 @@ def test_quote_only_prints_the_table_and_submits_nothing(fake, tmp_path): assert ETH_ROUTE in printed and "evm-hyperlane" in printed and "quote only" in printed +def test_the_case_records_veils_benchmark_marks(fake, tmp_path): + benchmark = live_helpers.LiveBenchmark("evm-hyperlane", log=lambda _: None) + live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=tmp_path / "s.json", + execute=False, benchmark=benchmark, log=lambda _: None) + assert [mark.step for mark in benchmark.marks] == ["plan-prepared", "quote-returned"] + + def test_quote_only_never_creates_the_private_mint_secret(fake, tmp_path): state_path = tmp_path / "evm-xreserve.json" live_cases.run_case(fake, "evm-xreserve", USDC_ROUTE, state_path=state_path, execute=False, From 33aadd61c3dfe290a9496919e58c56a78baa955c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 16:26:34 -0400 Subject: [PATCH 91/94] test(bridge-sdk): live lifecycle suite over every route, back and forth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge-sdk/tests/live/cases.py | 100 ++++- bridge-sdk/tests/live/config.py | 86 ++++- bridge-sdk/tests/live/test_lifecycle_live.py | 382 +++++++++++++++++++ bridge-sdk/tests/test_live_helpers.py | 121 ++++++ 4 files changed, 680 insertions(+), 9 deletions(-) create mode 100644 bridge-sdk/tests/live/test_lifecycle_live.py diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py index b780351c..310e7658 100644 --- a/bridge-sdk/tests/live/cases.py +++ b/bridge-sdk/tests/live/cases.py @@ -34,9 +34,12 @@ from .config import one_atomic_unit from .helpers import (LiveBenchmark, LiveCaseError, LiveState, Underfunded, ensure_secret_nonce, - load_live_state, load_secret_nonce, redacted, save_live_state, + load_live_state, load_secret_nonce, redacted, save_live_state, wait_for, wait_for_aleo_transaction, wait_for_hyperlane_delivery) +#: ERC-20 ``Transfer(address,address,uint256)``. +_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + #: veil's per-test budget (`30 * 60_000`). CASE_TIMEOUT_SECONDS = 30 * 60.0 WAIT_TIMEOUT_SECONDS = 20 * 60.0 @@ -329,11 +332,73 @@ def _select_private_record(bridge: Any, route: Route, amount_atomic: int, return record +def delivery_is_a_balance_rise(route: Route, registry: Registry) -> bool: + """True for the Aleo→EVM xReserve withdrawal, the one leg with no delivery query anywhere. + + ``lifecycle.py`` says it in as many words ("xReserve Aleo→EVM: Circle exposes no canonical + delivery query") and simply returns the receipt unchanged, so its status stays + ``DELIVERY_PENDING`` for ever and ``wait`` can only ever time out. veil does not drive this + case to ``done`` either — ``aleo-xreserve.live.test.ts:146-155`` polls the recipient's ERC-20 + balance until it rises above what it was before the burn, and that is what delivery means here. + """ + if route.protocol != "xreserve": + return False + source = registry.chain(registry.asset(route.source_asset_id).chain_id).family + destination = registry.chain(registry.asset(route.destination_asset_id).chain_id).family + return source == "aleo" and destination == "evm" + + +def _wait_for_balance_rise(bridge: Any, asset_id: str, before: int, *, timeout_seconds: float, + poll_seconds: float, log: Callable[[str], None]) -> int: + """veil ``waitFor(... balanceOf > destinationBalanceBefore)``: the recipient's balance, once it rises.""" + def read() -> Any: + after = read_balances(bridge).get(asset_id) + return after if after is not None and after > before else None + + log(f" awaiting {asset_id} to rise above {before} atomic (no delivery query exists for this leg)") + return wait_for(read, timeout_seconds=timeout_seconds, poll_seconds=poll_seconds) + + +def _evm_transfer_tx(bridge: Any, asset: Asset, recipient: str, amount_atomic: int, + lookback_blocks: int = 5_000) -> str | None: + """The transaction that moved *amount_atomic* of *asset* to *recipient*, or None. + + Best effort only: this is a convenience id for the report, so a public RPC that refuses the + log range (or returns nothing) leaves ``destinationTxId`` unset rather than failing a leg whose + funds have demonstrably arrived. + """ + connection = getattr(bridge, "ethereum", None) + if connection is None or asset.locator.kind != "evm-contract": + return None + try: + w3 = connection.w3 + head = w3.eth.block_number + entries = w3.eth.get_logs({ + "fromBlock": max(head - lookback_blocks, 0), "toBlock": head, + "address": w3.to_checksum_address(asset.locator.value), + "topics": [_TRANSFER_TOPIC, None, "0x" + "00" * 12 + recipient[2:].lower()]}) + except Exception: # noqa: BLE001 — a missing id is not a failure + return None + for entry in reversed(list(entries)): + raw = entry["data"] + value = int(raw.hex() if hasattr(raw, "hex") else raw, 16) + if value == amount_atomic: + digest = entry["transactionHash"] + return "0x" + (digest.hex() if hasattr(digest, "hex") else str(digest)).removeprefix("0x") + return None + + def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, recipient: str | None = None, amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, wait_timeout_seconds: float = WAIT_TIMEOUT_SECONDS, wait_poll_seconds: float = WAIT_POLL_SECONDS, - log: Callable[[str], None] = print) -> LiveState: - """Run one case over one route, resuming from ``state_path``. See the module docstring for the flow.""" + stop_after_execute: bool = False, log: Callable[[str], None] = print) -> LiveState: + """Run one case over one route, resuming from ``state_path``. See the module docstring for the flow. + + ``stop_after_execute=True`` returns as soon as the source transaction is on chain and its + checkpoint is on disk, so the caller can throw the whole client away and prove that a *new* + ``Bridge`` finishes the transfer from the state file alone. Calling ``run_case`` again with the + same ``state_path`` takes the resume branch; ``execute`` is never called twice for one transfer. + """ spec = CASES[case] state_path = Path(state_path) benchmark = benchmark if benchmark is not None else LiveBenchmark(case, log=log) @@ -394,6 +459,10 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r # recovering, so a rejected execution is reported as itself rather than as a timeout. wait_for_aleo_transaction(bridge, state.source_tx_id) benchmark.mark("source-confirmed") + if stop_after_execute: + log(f" handover source={state.source_tx_id} checkpoint on disk at {state_path}; " + "a new client will recover it") + return state else: log(f" resuming {case} {route_id} from the saved checkpoint") @@ -404,6 +473,23 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r return state progress = _recover_from_state(bridge, state, benchmark=benchmark, log=log) + + if delivery_is_a_balance_rise(route, bridge.registry): + # No drive loop: `wait` on this leg can only time out (see delivery_is_a_balance_rise). + before = int(state.destination_balance_before or 0) + after = _wait_for_balance_rise(bridge, destination.id, before, timeout_seconds=wait_timeout_seconds, + poll_seconds=wait_poll_seconds, log=log) + benchmark.mark("destination-delivered") + state.destination_tx_id = state.destination_tx_id or _evm_transfer_tx( + bridge, destination, recipient, after - before) + log(f" delivered {destination.id} +{after - before} atomic (before {before}, after {after}) " + f"tx={state.destination_tx_id}") + state.completed = True + save_live_state(state_path, state) + log(f" done source={state.source_tx_id} destination={state.destination_tx_id}") + log(f" {benchmark.summary()}") + return state + progress = _drive(bridge, progress, state, state_path, spec=spec, secret_nonce=secret_nonce, benchmark=benchmark, save=save, wait_timeout_seconds=wait_timeout_seconds, wait_poll_seconds=wait_poll_seconds, log=log) @@ -462,8 +548,8 @@ def run(bridge: Any, route_id: str, *, state_path: Path | str, recipient: str | __all__ = [ "CASES", "CASE_NAMES", "CASE_TIMEOUT_SECONDS", "CaseSpec", "LiveCaseError", "RUNNERS", "Underfunded", - "asset_ref", "case_for_route", "default_amount", "default_recipient", "precheck", "print_quote", - "read_balances", "route_slug", "routes_for_case", "run_aleo_hyperlane", "run_aleo_xreserve", - "run_case", "run_evm_hyperlane", "run_evm_xreserve", "run_solana_hyperlane", "sender_for", - "state_name", + "asset_ref", "case_for_route", "default_amount", "default_recipient", "delivery_is_a_balance_rise", + "precheck", "print_quote", "read_balances", "route_slug", "routes_for_case", "run_aleo_hyperlane", + "run_aleo_xreserve", "run_case", "run_evm_hyperlane", "run_evm_xreserve", "run_solana_hyperlane", + "sender_for", "state_name", ] diff --git a/bridge-sdk/tests/live/config.py b/bridge-sdk/tests/live/config.py index e0572fe5..6f1ee9e5 100644 --- a/bridge-sdk/tests/live/config.py +++ b/bridge-sdk/tests/live/config.py @@ -36,6 +36,31 @@ ENVIRONMENTS = ("mainnet", "testnet") +#: Endpoints used when the operator's shell names none. Public, read-mostly, no credentials. +DEFAULT_ALEO_ENDPOINT = "https://edge.provable.com/api" +DEFAULT_ETHEREUM_RPC_URL = "https://ethereum-rpc.publicnode.com" +DEFAULT_SEPOLIA_RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com" + +#: Key and RPC variables per environment, most specific first. The live suite resolves connection +#: material ONLY through these lists, so a testnet run can never pick up the mainnet Aleo key. +ALEO_KEY_VARS = { + "mainnet": ("BRIDGE_PRIVATE_KEY",), + "testnet": ("BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY", "ALEO_E2E_PRIVATE_KEY"), +} +EVM_KEY_VARS = { + "mainnet": ("EVM_PRIVATE_KEY", "BRIDGE_EVM_PRIVATE_KEY"), + "testnet": ("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "EVM_PRIVATE_KEY", "BRIDGE_EVM_PRIVATE_KEY"), +} +EVM_RPC_VARS = { + "mainnet": ("ETHEREUM_RPC_URL", "BRIDGE_LIVE_ETHEREUM_RPC_URL"), + "testnet": ("SEPOLIA_RPC_URL", "BRIDGE_LIVE_SEPOLIA_RPC_URL"), +} +DEFAULT_EVM_RPC_URL = {"mainnet": DEFAULT_ETHEREUM_RPC_URL, "testnet": DEFAULT_SEPOLIA_RPC_URL} +ALEO_ENDPOINT_VARS = ("BRIDGE_LIVE_ALEO_ENDPOINT", "ALEO_ENDPOINT") + +#: The Aleo network name each bridge environment runs on. +ALEO_NETWORKS = {"mainnet": "mainnet", "testnet": "testnet"} + #: Recipient overrides per destination-chain family; the default is our own address on that chain. RECIPIENT_VARS = { "aleo": "BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT", @@ -123,6 +148,60 @@ def case_route_override(case: str, env: Mapping[str, str] | None = None) -> str return value(f"BRIDGE_LIVE_{case.replace('-', '_').upper()}_ROUTE_ID", env) +def case_amount_override(case: str, env: Mapping[str, str] | None = None) -> str | None: + """``BRIDGE_LIVE__AMOUNT``, or ``BRIDGE_LIVE_XRESERVE_AMOUNT`` for either xReserve case.""" + specific = value(f"BRIDGE_LIVE_{case.replace('-', '_').upper()}_AMOUNT", env) + if specific: + return specific + return value("BRIDGE_LIVE_XRESERVE_AMOUNT", env) if case.endswith("xreserve") else None + + +def _environment(environment: str) -> str: + if environment not in ENVIRONMENTS: + raise LiveConfigError(f"environment must be one of {ENVIRONMENTS}, got {environment!r}") + return environment + + +def first_value(names: tuple[str, ...], env: Mapping[str, str] | None = None) -> tuple[str, str] | None: + """The first of *names* that is set, as ``(variable, value)`` — or None. Never logs the value.""" + for name in names: + found = value(name, env) + if found: + return name, found + return None + + +def aleo_private_key(environment: str, env: Mapping[str, str] | None = None) -> str: + """The Aleo key for *environment* (``BRIDGE_PRIVATE_KEY`` on mainnet, ``ALEO_E2E_PRIVATE_KEY`` + — or its ``BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`` alias — on testnet).""" + names = ALEO_KEY_VARS[_environment(environment)] + found = first_value(names, env) + if found is None: + raise LiveConfigError(f"Missing {' / '.join(names)}; the {environment} live case needs an Aleo key") + return found[1] + + +def evm_private_key(environment: str, env: Mapping[str, str] | None = None) -> str: + """The EVM key for *environment*, normalised to ``0x…`` (the value never appears in an error).""" + names = EVM_KEY_VARS[_environment(environment)] + found = first_value(names, env) + if found is None: + raise LiveConfigError(f"Missing {' / '.join(names)}; the {environment} live case needs an EVM key") + return required_evm_private_key(found[0], env) + + +def evm_rpc_url(environment: str, env: Mapping[str, str] | None = None) -> str: + """The Ethereum (mainnet) or Sepolia (testnet) RPC url, falling back to the public default.""" + found = first_value(EVM_RPC_VARS[_environment(environment)], env) + return found[1] if found else DEFAULT_EVM_RPC_URL[environment] + + +def aleo_endpoint(env: Mapping[str, str] | None = None) -> str: + """The Aleo API root, falling back to the open, credential-free edge host.""" + found = first_value(ALEO_ENDPOINT_VARS, env) + return found[1] if found else DEFAULT_ALEO_ENDPOINT + + def recipient_override(family: str, env: Mapping[str, str] | None = None) -> str | None: """The operator's recipient override for a destination-chain *family*, or None (use our own address).""" try: @@ -133,9 +212,12 @@ def recipient_override(family: str, env: Mapping[str, str] | None = None) -> str __all__ = [ - "CASE_NAMES", "ENVIRONMENTS", "FUNDS_VAR", "LiveConfigError", "MAINNET_ACK", "MAINNET_ACK_VAR", + "ALEO_ENDPOINT_VARS", "ALEO_KEY_VARS", "ALEO_NETWORKS", "CASE_NAMES", "DEFAULT_ALEO_ENDPOINT", + "DEFAULT_ETHEREUM_RPC_URL", "DEFAULT_EVM_RPC_URL", "DEFAULT_SEPOLIA_RPC_URL", "ENVIRONMENTS", + "EVM_KEY_VARS", "EVM_RPC_VARS", "FUNDS_VAR", "LiveConfigError", "MAINNET_ACK", "MAINNET_ACK_VAR", "MAINNET_CASES_VAR", "MAINNET_EXECUTE_ACK", "MAINNET_EXECUTE_VAR", "RECIPIENT_VARS", "STATE_DIR_VAR", - "case_route_override", "live_funds_enabled", "live_state_path", "mainnet_case_enabled", + "aleo_endpoint", "aleo_private_key", "case_amount_override", "case_route_override", "evm_private_key", + "evm_rpc_url", "first_value", "live_funds_enabled", "live_state_path", "mainnet_case_enabled", "mainnet_execution_enabled", "one_atomic_unit", "recipient_override", "required", "required_evm_private_key", "state_dir", "value", ] diff --git a/bridge-sdk/tests/live/test_lifecycle_live.py b/bridge-sdk/tests/live/test_lifecycle_live.py new file mode 100644 index 00000000..663187fe --- /dev/null +++ b/bridge-sdk/tests/live/test_lifecycle_live.py @@ -0,0 +1,382 @@ +"""The funded live suite: every registry route, back and forth, through the public lifecycle verbs. + +One test per veil case, parametrized over every route that case covers. The parameter set is built +by ENUMERATING the registry, not by listing route ids by hand, so a new route cannot be added +without a case: :func:`_by_case` raises at import when a route has no case, and a route whose +``availability`` is not ``active`` is parametrized and skipped with ``registry:`` +rather than dropped. Plus the testnet deposit (`xreserve:sepolia/usdc->aleo-testnet/usdcx`) and, +beyond veil, the testnet RETURN leg (`xreserve:aleo-testnet/usdcx->sepolia/usdc`). + +Gates (read only — nothing here sets, exports or prints a settable acknowledgement): + +* ``BRIDGE_LIVE_FUNDS=1`` + ``BRIDGE_LIVE_STATE_DIR`` — the funded tests exist at all. +* ``BRIDGE_LIVE_MAINNET_ACK`` + ``BRIDGE_LIVE_MAINNET_CASES`` — a named mainnet case may run. +* ``BRIDGE_LIVE_MAINNET_EXECUTE`` — the wallet may submit. Without it every mainnet case runs to + the quote and returns (veil's ``if (!mainnetExecutionEnabled()) return``), which is how the + quote-only sweep in the report was produced. + +Every funded test goes through the disk: phase one quotes, prechecks and executes, then RETURNS — +the client is thrown away; phase two builds a brand new :class:`Bridge` over the same +``FileCheckpointStore`` and finishes the transfer from ``bridge.pending()`` / ``bridge.recover()``. +``execute`` is called at most once per transfer, ever; a timeout is *pending* (skip + the resume +command), never a failure verdict, and ``Underfunded`` is a skip printing the shortfall. +""" +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, Callable + +import pytest + +from aleo_bridge.errors import InsufficientBalanceError, PollingTimeoutError +from aleo_bridge.registry import DEFAULT_REGISTRY, Route + +from . import cases as live_cases +from . import config as live_config +from .helpers import LiveBenchmark, LiveTimeoutError, Underfunded + +pytestmark = pytest.mark.live + +#: The testnet pair. The deposit amount is 3 USDC (controller ruling 2026-09-18): the minted 3 +#: USDCx clears the 2 USDCx withdrawal fee so the return leg can burn veil's 2.000001 afterwards. +TESTNET_DEPOSIT_ROUTE = "xreserve:sepolia/usdc->aleo-testnet/usdcx" +TESTNET_RETURN_ROUTE = "xreserve:aleo-testnet/usdcx->sepolia/usdc" +TESTNET_DEPOSIT_AMOUNT = "3" +TESTNET_RETURN_AMOUNT = "2.000001" # veil mainnet/aleo-xreserve.live.test.ts:97 + + +# ── parametrization: enumerate the registry, leave nothing out ─────────────── + +def _by_case(environment: str) -> dict[str, list[Route]]: + """``case → routes`` for *environment*, asserting that every route has a case. + + Hermetic: the registry is static data, so this runs at import with no network and no keys. + """ + buckets: dict[str, list[Route]] = {name: [] for name in live_cases.CASE_NAMES} + uncovered: list[str] = [] + for route in DEFAULT_REGISTRY.routes(environment=environment): + name = live_cases.case_for_route(DEFAULT_REGISTRY, route) + if name is None: + uncovered.append(route.id) + else: + buckets[name].append(route) + if uncovered: + raise AssertionError( + f"No live case covers these {environment} routes: {sorted(uncovered)} — add the case " + "to tests/live/cases.py::CASES rather than narrowing this suite") + return buckets + + +MAINNET_ROUTES = _by_case("mainnet") +TESTNET_ROUTES = _by_case("testnet") + + +def _ids(routes: list[Route]) -> list[str]: + return [route.id for route in routes] + + +def _params(case: str) -> Any: + """``pytest.mark.parametrize`` arguments for one case: every mainnet route it covers.""" + routes = MAINNET_ROUTES[case] + return pytest.mark.parametrize("route", routes, ids=_ids(routes)) + + +# ── clients ────────────────────────────────────────────────────────────────── + +def _build_bridge(environment: str) -> Any: + """A fresh client for *environment* — new facade, new connections, same checkpoint store. + + Keys and endpoints are resolved only through ``config``: the testnet bridge can never pick up + the mainnet Aleo key, and an unset RPC variable falls back to the public default. + """ + from aleo_bridge import Bridge + from aleo_bridge.checkpoint import FileCheckpointStore + from aleo_bridge.client import build_aleo + from aleo_bridge.eth import Ethereum + from aleo_bridge.sol import Solana + + aleo = build_aleo(live_config.aleo_endpoint(), live_config.ALEO_NETWORKS[environment], + live_config.aleo_private_key(environment)) + ethereum = Ethereum(live_config.evm_rpc_url(environment), + private_key=live_config.evm_private_key(environment)) + solana = Solana.from_env() if environment == "mainnet" else None + store = FileCheckpointStore(live_config.state_dir() / environment / "checkpoints") + bridge = Bridge(aleo, ethereum=ethereum, solana=solana, checkpoints=store) + assert bridge.environment == environment + return bridge + + +#: Opt-in for sharing a MAINNET view key with the hosted record scanner (see below). +SCANNER_VAR = "BRIDGE_LIVE_ALEO_SCANNER" + + +def _register_record_scanner(bridge: Any, environment: str) -> None: + """Register the Aleo account with the hosted record scanner, which private selection needs. + + ``bridge.privacy.select_record`` (the record the xReserve burn spends) goes through + ``aleo.records.find``, and the scanner answers nothing for an account it has not been + registered for — it raises ``UUIDError`` instead. Registration **shares that account's view + key** with the scanning service, which can then decrypt every record the account owns. + + That is a decision about somebody's privacy, so it is automatic only for the testnet e2e key. + On mainnet the case skips unless ``BRIDGE_LIVE_ALEO_SCANNER=1`` says the operator accepts it; + nothing here ever sets that variable. + """ + if environment != "testnet" and live_config.value(SCANNER_VAR) != "1": + pytest.skip(f"private-record selection needs the hosted record scanner, and registering " + f"shares this account's view key with it — set {SCANNER_VAR}=1 to accept that") + result = bridge.aleo.records.register(bridge.aleo.default_account) + ok = result.get("ok") if isinstance(result, dict) else result + print(f" scanner hosted record scanner registered for {bridge.aleo_address()} (ok={ok})") + + +def _client(make_bridge: Callable[[], Any], case: str, environment: str, *, execute: bool) -> Any: + """A fresh bridge, with the record scanner registered for the cases that select private records. + + Only when we are actually going to burn: a quote-only rehearsal never selects a record, so it + must not make a view-key decision (nor skip for want of one) on the operator's behalf. + """ + bridge = make_bridge() + if execute and case == "aleo-xreserve": + _register_record_scanner(bridge, environment) + return bridge + + +@pytest.fixture +def mainnet_client() -> Callable[[], Any]: + return lambda: _build_bridge("mainnet") + + +@pytest.fixture +def testnet_client() -> Callable[[], Any]: + return lambda: _build_bridge("testnet") + + +# ── the shared body ────────────────────────────────────────────────────────── + +def _state_path(case: str, route: Route, environment: str) -> Path: + return live_config.live_state_path(environment, live_cases.state_name(case, route.id)) + + +def resume_command(state_path: Path | str) -> str: + """What the operator types to continue an interrupted transfer (the CLI, not a test run).""" + return f"python scripts/rehearse.py --recover {state_path}" + + +def _destination_family(route: Route) -> str: + asset = DEFAULT_REGISTRY.asset(route.destination_asset_id) + return DEFAULT_REGISTRY.chain(asset.chain_id).family + + +def _record(record_property: Callable[[str, Any], None], case: str, route: Route, state: Any, + benchmark: LiveBenchmark) -> None: + record_property("case", case) + record_property("route_id", route.id) + record_property("source_tx_id", getattr(state, "source_tx_id", None)) + record_property("message_id", getattr(state, "message_id", None)) + record_property("destination_tx_id", getattr(state, "destination_tx_id", None)) + record_property("destination_balance_before", getattr(state, "destination_balance_before", None)) + record_property("completed", bool(getattr(state, "completed", False))) + record_property("benchmark_ms", benchmark.as_dict()) + + +def _drive_case(make_bridge: Callable[[], Any], case: str, route: Route, *, environment: str, + execute: bool, record_property: Callable[[str, Any], None], + amount: str | None = None) -> Any: + """Quote (and, when acknowledged, execute) one route, then finish it from disk with a NEW client. + + The two phases are the point: whatever ``execute`` returned is dropped with the first client, so + the transfer can only reach ``done`` through ``bridge.pending()`` → ``bridge.recover()`` → + ``wait``/``resume``/``complete`` on a process that never saw it start. + """ + if not route.active: + pytest.skip(f"registry:{route.availability}") + + state_path = _state_path(case, route, environment) + benchmark = LiveBenchmark(f"{case}:{live_cases.route_slug(route.id)}", log=print) + amount = amount or live_config.case_amount_override(case) + recipient = live_config.recipient_override(_destination_family(route)) + deadline = time.monotonic() + live_cases.CASE_TIMEOUT_SECONDS + + def remaining() -> float: + return max(deadline - time.monotonic(), 0.0) + + def run(bridge: Any, *, stop_after_execute: bool) -> Any: + return live_cases.run_case( + bridge, case, route.id, state_path=state_path, recipient=recipient, amount=amount, + execute=execute, benchmark=benchmark, stop_after_execute=stop_after_execute, + wait_timeout_seconds=min(live_cases.WAIT_TIMEOUT_SECONDS, remaining()), + wait_poll_seconds=live_cases.WAIT_POLL_SECONDS, log=print) + + state = None + try: + bridge = _client(make_bridge, case, environment, execute=execute) + benchmark.mark("clients-created") + state = run(bridge, stop_after_execute=True) + if execute and state.checkpoint is not None and not state.completed: + print(f" handover dropping the client that executed {route.id}; " + "a new one will recover from disk") + del bridge # the in-memory progress goes with it + bridge = _client(make_bridge, case, environment, execute=execute) + benchmark.mark("clients-recreated") + state = run(bridge, stop_after_execute=False) + except Underfunded as exc: + record_property("underfunded", {"asset_id": exc.asset_id, "needed": exc.needed, + "have": exc.have, "shortfall": exc.shortfall, "what": exc.what}) + print(f"\n UNDERFUNDED {route.id}: {exc}") + pytest.skip(f"{route.id}: {exc}") + except InsufficientBalanceError as exc: + # Some quotes read the wallet themselves and refuse before our precheck ever runs (the + # xReserve deposit quote is one). That is the same verdict, reached one step earlier. + record_property("underfunded", {"raised_by": "quote", "detail": str(exc)}) + print(f"\n UNDERFUNDED {route.id}: the quote refused — {exc}") + pytest.skip(f"{route.id}: {exc}") + except (PollingTimeoutError, LiveTimeoutError) as exc: + record_property("pending_state_path", str(state_path)) + record_property("pending_resume", resume_command(state_path)) + print(f"\n PENDING {route.id} is still in flight: {exc}\n" + f" resume {resume_command(state_path)}") + pytest.skip(f"{route.id} still in flight (state {state_path}); resume: {resume_command(state_path)}") + + _record(record_property, case, route, state, benchmark) + if not execute: + # veil's `if (!mainnetExecutionEnabled()) return` — the quote and the precheck are the test, + # and the one thing that must hold is that nothing was submitted for an unstarted transfer. + assert state.completed or state.checkpoint is None, \ + f"{route.id} has an in-flight checkpoint that the quote-only run must not have created" + return state + + assert state.completed, f"{route.id} did not reach done: {state}" + assert state.source_tx_id, f"{route.id} completed without a source transaction id" + if not live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY): + assert state.message_id or state.destination_tx_id, \ + f"{route.id} completed without a message id or a destination transaction id" + + # Delivery is checked as "at least what was quoted", never as equality. A quote's `amount_out` + # is derived from the registry's fee literal, and the fee the protocol actually charges is live + # state: the 2026-09-18 testnet return burned 2.000001 USDCx against a registry + # withdrawalFeeAtomic of 2_000_000 (quote: 0.000001 USDC out) and delivered 0.996501 USDC, + # because Circle's testnet withdrawal fee was 1.0035 USDC that day. A private mint moves no + # public balance at all, so a zero delta is also correct. + if state.destination_balance_before is not None: + after = live_cases.read_balances(bridge).get(route.destination_asset_id) + if after is not None: + delta = after - int(state.destination_balance_before) + record_property("destination_balance_delta", delta) + print(f" delta {route.destination_asset_id} +{delta} atomic " + f"(before {state.destination_balance_before}, after {after})") + assert delta >= 0, f"{route.id} delivered a negative balance delta ({delta})" + if live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY): + assert delta > 0, f"{route.id} was marked delivered but nothing arrived" + print(f"\n SUMMARY {case} {route.id} source={state.source_tx_id} " + f"message={state.message_id} destination={state.destination_tx_id}") + return state + + +def _mainnet(case: str, route: Route, make_bridge: Callable[[], Any], + record_property: Callable[[str, Any], None]) -> Any: + """Gate one mainnet route: funds + the case acknowledgement; submission needs its own.""" + if not live_config.live_funds_enabled(): + pytest.skip(f"set {live_config.FUNDS_VAR}=1 and {live_config.STATE_DIR_VAR} to run funded live cases") + if not live_config.mainnet_case_enabled(case): + pytest.skip(f"{live_config.MAINNET_ACK_VAR} and {live_config.MAINNET_CASES_VAR} do not enable {case}") + return _drive_case(make_bridge, case, route, environment="mainnet", + execute=live_config.mainnet_execution_enabled(), record_property=record_property) + + +def _testnet(case: str, route_id: str, make_bridge: Callable[[], Any], + record_property: Callable[[str, Any], None], amount: str) -> Any: + """Gate one testnet route: the funds gate alone (veil's testnet file has no mainnet acknowledgement).""" + if not live_config.live_funds_enabled(): + pytest.skip(f"set {live_config.FUNDS_VAR}=1 and {live_config.STATE_DIR_VAR} to run funded live cases") + route = DEFAULT_REGISTRY.route(route_id) + return _drive_case(make_bridge, case, route, environment="testnet", execute=True, + record_property=record_property, amount=amount) + + +# ── the five mainnet cases, one test each, over every route they cover ─────── + +@_params("evm-hyperlane") +def test_evm_hyperlane(route, mainnet_client, record_property): + """veil mainnet/evm-hyperlane.live.test.ts: ethereum → aleo over the Hyperlane warp routes.""" + _mainnet("evm-hyperlane", route, mainnet_client, record_property) + + +@_params("evm-xreserve") +def test_evm_xreserve(route, mainnet_client, record_property): + """veil mainnet/evm-xreserve.live.test.ts: 2 USDC ethereum → aleo, private mint (needs `complete`).""" + _mainnet("evm-xreserve", route, mainnet_client, record_property) + + +@_params("aleo-hyperlane") +def test_aleo_hyperlane(route, mainnet_client, record_property): + """veil mainnet/aleo-hyperlane.live.test.ts: aleo → ethereum and aleo → solana, `mode="signer"`.""" + _mainnet("aleo-hyperlane", route, mainnet_client, record_property) + + +@_params("aleo-xreserve") +def test_aleo_xreserve(route, mainnet_client, record_property): + """veil mainnet/aleo-xreserve.live.test.ts: 2.000001 USDCx private burn, aleo → ethereum.""" + _mainnet("aleo-xreserve", route, mainnet_client, record_property) + + +@_params("solana-hyperlane") +def test_solana_hyperlane(route, mainnet_client, record_property): + """veil mainnet/solana-hyperlane.live.test.ts: 1 lamport solana → aleo.""" + _mainnet("solana-hyperlane", route, mainnet_client, record_property) + + +# ── recovery for real (controller note 10, brief deliverable 2) ────────────── + +@pytest.mark.parametrize("route", MAINNET_ROUTES["evm-hyperlane"][:1], + ids=_ids(MAINNET_ROUTES["evm-hyperlane"][:1])) +def test_evm_hyperlane_recovers_from_disk_after_execute(route, mainnet_client, record_property): + """The ETH route, finished by a client that never saw ``execute``. + + This is the same two-phase body every funded test uses, named separately because the parity doc + calls it out: the checkpoint written by phase one is looked up through ``bridge.pending()`` on a + brand-new ``Bridge`` bound to the same ``FileCheckpointStore``, rebuilt with ``bridge.recover`` + and driven to ``done`` with ``wait``. + """ + state = _mainnet("evm-hyperlane", route, mainnet_client, record_property) + if live_config.mainnet_execution_enabled(): + assert state.checkpoint is not None, "the transfer must have left a checkpoint on disk" + + +# ── testnet: the pair that actually runs today ─────────────────────────────── + +def test_testnet_evm_xreserve_deposit(testnet_client, record_property): + """veil testnet/evm-xreserve.live.test.ts: 3 USDC Sepolia → aleo-testnet USDCx, private mint. + + 3 rather than veil's 2 (controller ruling 2026-09-18) so the minted balance clears the 2 USDCx + withdrawal fee and :func:`test_testnet_aleo_xreserve_return` can burn straight afterwards. + """ + _testnet("evm-xreserve", TESTNET_DEPOSIT_ROUTE, testnet_client, record_property, + TESTNET_DEPOSIT_AMOUNT) + + +def test_testnet_aleo_xreserve_return(testnet_client, record_property): + """Beyond veil: the RETURN leg, aleo-testnet USDCx → Sepolia USDC, 2.000001 private burn. + + Runs the mainnet ``aleo-xreserve`` case function against the testnet route, so "all the routes + back and forth" holds on testnet too. It spends what the deposit minted — run that first; an + unfunded run skips with the shortfall. + """ + _testnet("aleo-xreserve", TESTNET_RETURN_ROUTE, testnet_client, record_property, + TESTNET_RETURN_AMOUNT) + + +# ── the coverage invariant, as a test as well as an import-time assertion ──── + +def test_every_registry_route_has_a_case(): + """'All the routes back and forth': no route in either environment may be left without a case.""" + for environment in live_config.ENVIRONMENTS: + buckets = _by_case(environment) + listed = {route.id for routes in buckets.values() for route in routes} + assert listed == {route.id for route in DEFAULT_REGISTRY.routes(environment=environment)} + assert {route.id for route in TESTNET_ROUTES["evm-xreserve"]} == {TESTNET_DEPOSIT_ROUTE} + assert {route.id for route in TESTNET_ROUTES["aleo-xreserve"]} == {TESTNET_RETURN_ROUTE} + + +__all__ = ["MAINNET_ROUTES", "TESTNET_ROUTES", "resume_command"] diff --git a/bridge-sdk/tests/test_live_helpers.py b/bridge-sdk/tests/test_live_helpers.py index a09d97f8..f71d1b05 100644 --- a/bridge-sdk/tests/test_live_helpers.py +++ b/bridge-sdk/tests/test_live_helpers.py @@ -856,3 +856,124 @@ def test_the_table_renders_one_line_per_route(rehearse): table = rehearse.render_table(rows) assert ETH_ROUTE in table and "quote-only" in table and "metadata-required" in table assert len(table.strip().splitlines()) >= 3 # header + two rows + + +# ══ 13b: environment aliases, the execute handover, the suite's parametrization ══ + +def test_key_and_rpc_variables_resolve_per_environment(monkeypatch): + """A testnet run may never reach for the mainnet Aleo key, and an unset RPC has a default.""" + monkeypatch.setenv("BRIDGE_PRIVATE_KEY", "APrivateKey1zkpMainnet") + monkeypatch.setenv("ALEO_E2E_PRIVATE_KEY", "APrivateKey1zkpTestnet") + assert live_config.aleo_private_key("mainnet") == "APrivateKey1zkpMainnet" + assert live_config.aleo_private_key("testnet") == "APrivateKey1zkpTestnet" + + monkeypatch.setenv("BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY", "APrivateKey1zkpAlias") + assert live_config.aleo_private_key("testnet") == "APrivateKey1zkpAlias" # the alias wins + assert live_config.aleo_private_key("mainnet") == "APrivateKey1zkpMainnet" + + monkeypatch.delenv("BRIDGE_PRIVATE_KEY") + with pytest.raises(live_config.LiveConfigError, match="BRIDGE_PRIVATE_KEY"): + live_config.aleo_private_key("mainnet") + with pytest.raises(live_config.LiveConfigError, match="environment"): + live_config.aleo_private_key("devnet") + + +def test_evm_key_is_normalised_and_the_value_never_appears(monkeypatch): + monkeypatch.setenv("BRIDGE_EVM_PRIVATE_KEY", "0x" + "AB" * 32) + assert live_config.evm_private_key("mainnet") == "0x" + "ab" * 32 + assert live_config.evm_private_key("testnet") == "0x" + "ab" * 32 + + monkeypatch.setenv("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "0x" + "cd" * 32) + assert live_config.evm_private_key("testnet") == "0x" + "cd" * 32 + assert live_config.evm_private_key("mainnet") == "0x" + "ab" * 32 + + monkeypatch.setenv("BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY", "not-a-key") + with pytest.raises(live_config.LiveConfigError) as excinfo: + live_config.evm_private_key("testnet") + assert "not-a-key" not in str(excinfo.value) + + +def test_rpc_urls_fall_back_to_the_public_defaults(monkeypatch): + for name in ("SEPOLIA_RPC_URL", "BRIDGE_LIVE_SEPOLIA_RPC_URL", "ETHEREUM_RPC_URL", + "BRIDGE_LIVE_ETHEREUM_RPC_URL", "ALEO_ENDPOINT", "BRIDGE_LIVE_ALEO_ENDPOINT"): + monkeypatch.delenv(name, raising=False) + assert live_config.evm_rpc_url("mainnet") == live_config.DEFAULT_ETHEREUM_RPC_URL + assert live_config.evm_rpc_url("testnet") == live_config.DEFAULT_SEPOLIA_RPC_URL + assert live_config.aleo_endpoint() == live_config.DEFAULT_ALEO_ENDPOINT + + monkeypatch.setenv("SEPOLIA_RPC_URL", "https://sepolia.example") + monkeypatch.setenv("BRIDGE_LIVE_ETHEREUM_RPC_URL", "https://eth.example") + monkeypatch.setenv("ALEO_ENDPOINT", "https://aleo.example/api") + assert live_config.evm_rpc_url("testnet") == "https://sepolia.example" + assert live_config.evm_rpc_url("mainnet") == "https://eth.example" + assert live_config.aleo_endpoint() == "https://aleo.example/api" + assert live_config.first_value(("ABSENT_A", "SEPOLIA_RPC_URL")) == ("SEPOLIA_RPC_URL", "https://sepolia.example") + assert live_config.first_value(("ABSENT_A", "ABSENT_B")) is None + + +def test_amount_overrides_are_per_case_with_an_xreserve_shorthand(monkeypatch): + monkeypatch.delenv("BRIDGE_LIVE_XRESERVE_AMOUNT", raising=False) + assert live_config.case_amount_override("evm-xreserve") is None + monkeypatch.setenv("BRIDGE_LIVE_XRESERVE_AMOUNT", "3") + assert live_config.case_amount_override("evm-xreserve") == "3" + assert live_config.case_amount_override("aleo-xreserve") == "3" + assert live_config.case_amount_override("evm-hyperlane") is None # never a Hyperlane amount + monkeypatch.setenv("BRIDGE_LIVE_EVM_XRESERVE_AMOUNT", "5") + assert live_config.case_amount_override("evm-xreserve") == "5" + + +def _refuse_execute(*args, **kwargs): + raise AssertionError("execute must never be called for a transfer that already has a checkpoint") + + +def test_stop_after_execute_hands_the_transfer_over_through_the_state_file(fake, tmp_path): + """Phase one executes and returns; phase two must reach done without executing again.""" + state_path = tmp_path / "handover.json" + first = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + stop_after_execute=True, wait_timeout_seconds=1, wait_poll_seconds=0, + log=lambda _: None) + assert first.checkpoint is not None and first.completed is False and first.source_tx_id + assert state_path.exists() + + submitted = [event for event in fake.events if event[0] in {"evm_send", "submit"}] + fake.execute = _refuse_execute # the handover may only use the recovery verbs + fake.eth.recover_result = Receipt( + id=first.checkpoint["receiptId"], protocol="hyperlane", status=Status.COMPLETED, + source_tx_id=first.source_tx_id, destination_tx_id="at1delivered", + protocol_state={"routeId": ETH_ROUTE, "messageId": "0x" + "ee" * 32}) + second = live_cases.run_case(fake, "evm-hyperlane", ETH_ROUTE, state_path=state_path, execute=True, + wait_timeout_seconds=1, wait_poll_seconds=0, log=lambda _: None) + assert second.completed and second.source_tx_id == first.source_tx_id + assert [event for event in fake.events if event[0] in {"evm_send", "submit"}] == submitted + + +def test_the_suite_parametrizes_every_route_in_both_environments(): + from tests.live import test_lifecycle_live as suite + + for environment, buckets in (("mainnet", suite.MAINNET_ROUTES), ("testnet", suite.TESTNET_ROUTES)): + listed = {route.id for routes in buckets.values() for route in routes} + assert listed == {route.id for route in DEFAULT_REGISTRY.routes(environment=environment)} + + mainnet = {route.id: route for routes in suite.MAINNET_ROUTES.values() for route in routes} + assert "hyperlane:ethereum/usad->aleo/usad" in mainnet # metadata-required, parametrized + assert not mainnet["hyperlane:ethereum/usad->aleo/usad"].active + assert suite.TESTNET_DEPOSIT_ROUTE in {r.id for r in suite.TESTNET_ROUTES["evm-xreserve"]} + assert suite.TESTNET_RETURN_ROUTE in {r.id for r in suite.TESTNET_ROUTES["aleo-xreserve"]} + assert suite.TESTNET_DEPOSIT_AMOUNT == "3" and suite.TESTNET_RETURN_AMOUNT == "2.000001" + assert "--recover" in suite.resume_command("/tmp/state.json") + + +def test_only_the_aleo_to_evm_withdrawal_measures_delivery_by_balance(): + """The one leg with no delivery query anywhere: `wait` on it could only ever time out.""" + rise = {route.id for route in DEFAULT_REGISTRY.routes() + if live_cases.delivery_is_a_balance_rise(route, DEFAULT_REGISTRY)} + assert rise == {"xreserve:aleo/usdcx->ethereum/usdc", "xreserve:aleo-testnet/usdcx->sepolia/usdc"} + assert not live_cases.delivery_is_a_balance_rise(DEFAULT_REGISTRY.route(USDC_ROUTE), DEFAULT_REGISTRY) + assert not live_cases.delivery_is_a_balance_rise(DEFAULT_REGISTRY.route(ETH_ROUTE), DEFAULT_REGISTRY) + + +def test_the_suite_sets_no_acknowledgement_and_prints_no_settable_form(): + source = (SCRIPT.parent.parent / "tests" / "live" / "test_lifecycle_live.py").read_text(encoding="utf-8") + assert "export " not in source + assert live_config.MAINNET_ACK not in source and live_config.MAINNET_EXECUTE_ACK not in source + assert "setenv" not in source and "os.environ[" not in source From 07ed2a2ac4ed2eef5b717705064d619cbd608832 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 16:26:40 -0400 Subject: [PATCH 92/94] docs(bridge-sdk): live-test commands, and PyNaCl is a base dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: the exact invocations for quote-only, testnet and mainnet execution (the acknowledgement values shown only as ``, 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. --- bridge-sdk/README.md | 50 +++++++++++++++++++++++++++++++++++++-- bridge-sdk/pyproject.toml | 8 +++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 5b68fda6..06b5f19b 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -162,6 +162,23 @@ skipped-by-registry rather than dropped: | `evm-xreserve` | ethereum USDC → aleo USDCx | `2` USDC, private mint (needs `complete`) | | `aleo-xreserve` | aleo USDCx → ethereum USDC | `2.000001` USDCx private burn, delivers 1 atomic unit | +The `metadata-required` mainnet routes (ALEO on ethereum/solana/base/hyperevm, USAD) are +parametrized too and skip with `registry:metadata-required` — `tests/live/test_lifecycle_live.py` +builds its parameters by enumerating `DEFAULT_REGISTRY.routes(...)`, so a route that no case covers +raises at import rather than disappearing. The same two xReserve case functions run the testnet +pair (`xreserve:sepolia/usdc->aleo-testnet/usdcx` at `3` USDC, then +`xreserve:aleo-testnet/usdcx->sepolia/usdc` at `2.000001`); the testnet keys and RPC are +`BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`/`ALEO_E2E_PRIVATE_KEY`, +`BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY`/`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY` and +`SEPOLIA_RPC_URL`/`BRIDGE_LIVE_SEPOLIA_RPC_URL` (public default when unset). Testnet needs no +mainnet acknowledgement — the funds gate alone. + +**Recovery is not simulated.** Every funded test runs in two phases: the first quotes, prechecks +and calls `execute` once, then returns; the client that executed is discarded, and a brand-new +`Bridge` over the same `FileCheckpointStore` finishes the transfer from `bridge.pending()` → +`bridge.recover(checkpoint)` → `wait`/`resume`/`complete`. `execute` is never called twice for one +transfer, and `BRIDGE_LIVE_XRESERVE_AMOUNT` (or `BRIDGE_LIVE__AMOUNT`) overrides an amount. + **Funding per run** (from the 2026-09-17 read-only mainnet quote sweep): ETH/WBTC/USDT Hyperlane deposits cost ≈0.0000838 ETH each in native Hyperlane fees plus L1 gas (USDT also needs one approval); the Aleo-origin legs cost 8.174147 (ETH), 9.138947 (WBTC), 9.138947 (USDT) and 7.661056 @@ -171,10 +188,39 @@ and the burn needs an unspent private USDCx record of at least 2.000001. Return the matching inbound leg minted, so run inbound first — an unfunded return leg skips with its shortfall printed rather than failing. -**Running it.** +**Running it.** *Quote only* — prices and prechecks every route and submits nothing, whatever is +acknowledged: - python scripts/rehearse.py --case evm-hyperlane --quote-only # price every route, submit nothing + python scripts/rehearse.py --case evm-hyperlane --quote-only python scripts/rehearse.py --case evm-xreserve --report run.json # submits only if acknowledged + +*Testnet* (Sepolia ⇄ aleo-testnet, the funds gate only — no mainnet acknowledgement): + + BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_evm_xreserve_deposit + BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_aleo_xreserve_return + +*Mainnet, quote only* — the acknowledgement that names the cases, and deliberately no execute +variable, so each route is priced and prechecked and nothing is signed: + + BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES=evm-hyperlane,evm-xreserve,aleo-hyperlane,aleo-xreserve,solana-hyperlane \ + pytest -m live -s tests/live/test_lifecycle_live.py + +*Mainnet, for real* — **you** type this, in your own shell, for one command; both acknowledgement +values are the constants in `tests/live/config.py` and appear nowhere in this repository in a +copy-pasteable form. Nothing in the suite or the CLI ever sets them: + + BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES= \ + BRIDGE_LIVE_MAINNET_EXECUTE= \ + pytest -m live -s "tests/live/test_lifecycle_live.py::test_evm_xreserve" + +*Resuming* an interrupted transfer (the run prints this line itself): + python scripts/rehearse.py --recover "$BRIDGE_LIVE_STATE_DIR/mainnet/-.json" Exit codes: `0` ok, `1` a case failed, `2` something is still pending. The same case functions back diff --git a/bridge-sdk/pyproject.toml b/bridge-sdk/pyproject.toml index 820a7b48..16ebf33a 100644 --- a/bridge-sdk/pyproject.toml +++ b/bridge-sdk/pyproject.toml @@ -4,7 +4,11 @@ version = "0.1.0" description = "Python SDK for bridging assets between Aleo, Ethereum and Solana over Hyperlane warp routes and Circle xReserve" readme = "README.md" requires-python = ">=3.10" -dependencies = ["aleo-sdk>=0.5.0", "requests>=2"] +# PyNaCl is NOT optional: every Aleo-side execution goes through delegated proving +# (`proving="delegate"`, the default), and the proving request is sealed with a NaCl box before it +# leaves the machine — without it `complete`/`execute` raise ImportError at the last step, after the +# source transaction is already on chain. `aleo-sdk[dps]` pulls the same package. +dependencies = ["aleo-sdk>=0.5.0", "requests>=2", "pynacl>=1.5"] [project.optional-dependencies] evm = ["web3>=7,<9", "eth-account>=0.13"] @@ -12,7 +16,7 @@ solana = ["solders>=0.21", "solana>=0.35"] # mcp 2.0 renamed the Server registration API — pin to 1.x (same rule as shield-swap). mcp = ["mcp>=1.0,<2"] dev = ["pytest>=8", "pytest-asyncio>=0.23", "web3>=7,<9", "eth-account>=0.13", - "solders>=0.21", "solana>=0.35", "mcp>=1.0,<2"] + "solders>=0.21", "solana>=0.35", "mcp>=1.0,<2", "pynacl>=1.5"] [build-system] requires = ["hatchling"] From f4361a22fb2f886066ddd77b3ddc5954b1be5384 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 16:38:14 -0400 Subject: [PATCH 93/94] docs(bridge-sdk): rewrite README for the full lifecycle SDK, agent/MCP 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. --- bridge-sdk/README.md | 627 +++++++++++++++++++++---------- bridge-sdk/tests/test_package.py | 45 +++ 2 files changed, 476 insertions(+), 196 deletions(-) diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 06b5f19b..0264ae91 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -1,132 +1,305 @@ # aleo-bridge-sdk -Python SDK for bridging assets between Aleo, Ethereum and Solana over the reviewed Hyperlane warp -routes and Circle xReserve deployments — a port of veil's `@provablehq/aleo-bridge-sdk` 0.1.0 into -the web3.py-style verb structure of `aleo-sdk`. - -## Install - - pip install aleo-bridge-sdk # Aleo legs only - pip install 'aleo-bridge-sdk[evm]' # + Ethereum (web3, eth-account) - pip install 'aleo-bridge-sdk[solana]' # + Solana (solders, solana) - -## Use (Aleo side) - - from aleo import Aleo, HTTPProvider - from aleo_bridge import Bridge - - aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) - aleo.default_account = aleo.account.from_private_key(key) - bridge = Bridge(aleo) # or Bridge.from_env() / Bridge.from_profile() - - bridge.status() # addresses + public balances, read-only - bridge.hyperlane.quote_gas_payment("aleo/wbtc") - call = bridge.hyperlane.transfer_remote("aleo/wbtc", "0xRecipient", amount="0.0001", as_signer=True) - call.simulate() # local authorization, nothing sent - receipt = call.delegate() # DPS proves, fee master pays, broadcast - bridge.xreserve.burn("0xRecipient", amount="2.5") # private USDCx → USDC - bridge.shield("aleo/eth", amount="0.01"); bridge.unshield("aleo/usdcx", amount="2.5") - -Reads return values; writes return an `AleoCall` with `simulate() / prove() / transact() / delegate()`. -Lifecycle verbs (`quote → execute → wait`, `recover/resume/complete`), Solana origins, and the -agent/MCP surface arrive in the following plans. - -## Ethereum - - from web3 import Web3 - from aleo_bridge import Bridge, Ethereum - - bridge = Bridge(aleo, ethereum=Ethereum("https://eth.example/rpc", private_key=evm_key)) # SDK-built transport - bridge = Bridge(aleo, ethereum=Ethereum(w3=my_w3, signer=my_local_account)) # your Web3 + your signer - bridge = Bridge(aleo, ethereum=my_w3) # bare Web3: read-only, or signs via w3.eth.default_account middleware - bridge = Bridge.from_env() # EVM_PRIVATE_KEY + ETHEREUM_RPC_URL (both or neither); - # aliases BRIDGE_EVM_PRIVATE_KEY / BRIDGE_LIVE_ETHEREUM_RPC_URL - - quote = bridge.eth.quote_transfer_remote("wbtc", aleo_recipient, amount="0.001") - print(quote.native_fee_atomic, quote.approval_required) - - call = bridge.eth.transfer_remote("wbtc", aleo_recipient, amount="0.001") - call.build() # unsigned tx dicts: approve(s) then transferRemote - result = call.send(on_checkpoint=store.save) # approvals → dispatch; each hash checkpointed before polling - result.message_id, result.receipt.status # Hyperlane message id, DELIVERY_PENDING - - usdc_quote = bridge.eth.quote_deposit_usdc(aleo_recipient, amount="2", mint_mode="public") - print(usdc_quote.balance_atomic, usdc_quote.approval_required) - - deposit = bridge.eth.deposit_usdc(aleo_recipient, amount="2", mint_mode="public").send() - deposit.message_hash # Circle attestation lookup key (receipt id), ATTESTATION_PENDING - - bridge.eth.balance("eth"); bridge.eth.is_delivered(message_id) # reads - bridge.eth.source_status(plan, receipt) # one refresh of an approval/confirming receipt - bridge.eth.recover_source(plan, checkpoint) # log-scan recovery, never signs - -Routes: ETH (native), WBTC and USDT (collateral; USDT resets a non-zero allowance to 0 first) via Hyperlane; -USDC → USDCx via Circle xReserve (2 USDC minimum, `mint_mode` public/record/private — private deposits go to the -shielded wrapper program and need the same `secret_nonce` at `complete` time; the SDK never stores it). -A receipt timeout returns a pending receipt, never a failure. Live checks: `BRIDGE_LIVE_READS=1 ETHEREUM_RPC_URL=…` -for read-only mainnet quotes; `BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR=… SEPOLIA_RPC_URL=… EVM_PRIVATE_KEY=… -ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg. - -## Solana (SOL → Aleo over Hyperlane) - -Install the extra: `pip install 'aleo-bridge-sdk[solana]'` (solders + solana-py). - - from aleo_bridge import Bridge, Solana - - bridge = Bridge(aleo, solana=Solana(private_key=SOL_KEY)) # default RPC api.mainnet-beta.solana.com - bridge = Bridge(aleo, solana=Solana("https://my-rpc", signer=my_keypair)) # solders Keypair or any pubkey()/sign_message() signer - bridge = Bridge(aleo, solana=my_rpc_client) # bare RPC client (or solana-py AsyncClient) → read-only - - quote = bridge.sol.quote_transfer_remote(aleo_addr, amount="0.01") # amount + IGP + fee + rent, in lamports - call = bridge.sol.transfer_remote(aleo_addr, amount="0.01") - tx = call.build() # VersionedTransaction, unique-message key signed - result = call.send(on_checkpoint=store.save) # fee-payer signature, broadcast, poll to confirmed - result.message_id # Hyperlane message id from the Mailbox log - -`private_key` accepts a base58 secret (Phantom export) or the 64-int JSON array of a solana-cli `id.json`. -`Bridge.from_env()` reads `SOLANA_PRIVATE_KEY` and (optionally) `SOLANA_RPC_URL`. The default transport is the -SDK's own synchronous JSON-RPC client (`aleo_bridge.sol.SolanaRpcClient`, built on `requests`); pass `client=` to -reuse your own — anything with solana-py's read/send methods, or a solana-py `AsyncClient`. Every read uses confirmed -commitment; the transaction sets a 400 000 compute-unit limit; the `SOURCE_CONFIRMING` receipt (signature, -unique-message address, blockhash, last valid block height) is checkpointed before polling, and a polling -timeout returns the pending receipt rather than failing. `on_checkpoint` is optional — passing `checkpoints=store` -to `Bridge(...)` saves every checkpoint automatically, the same channel Ethereum and Aleo calls use. The instruction -encoding and account list are pinned byte-for-byte against a recorded mainnet transfer -(`tests/fixtures/sealevel-transfer-remote.json`). `Solana` supports `close()` and use as a context manager -(`with Solana(...) as solana:`) to release the wrapped client's resources. - -Live read-only checks (no key, no funds): `BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live/test_sol_reads.py -q -s` -decodes the live IGP account and prints a leg-11 quote for a pinned sender; `SOLANA_RPC_URL` overrides the public -default if it rate-limits. The funded round trip runs from `scripts/rehearse.py` (plan 4). - -## Environment - -`BRIDGE_PRIVATE_KEY` (required by `from_env`), `ALEO_ENDPOINT` (default `https://edge.provable.com/api`), -`ALEO_NETWORK` (`mainnet`|`testnet`), `ALEO_API_KEY`/`ALEO_CONSUMER_ID` (legacy hosts), -`EVM_PRIVATE_KEY`+`ETHEREUM_RPC_URL` (aliases `BRIDGE_EVM_PRIVATE_KEY`+`BRIDGE_LIVE_ETHEREUM_RPC_URL`, used by the -user's live shell/veil config; the primary variable wins when both are set), -`SOLANA_PRIVATE_KEY`(+`SOLANA_RPC_URL`) (aliases `BRIDGE_SOLANA_PRIVATE_KEY`+`BRIDGE_LIVE_SOLANA_RPC_URL`, -same precedence), `BRIDGE_CHECKPOINT_DIR`. -Note that `BRIDGE_LIVE_ETHEREUM_RPC_URL` and `BRIDGE_LIVE_SOLANA_RPC_URL` are not live-test-only: ordinary -`Ethereum.from_env()` / `Solana.from_env()` / `Bridge.from_env()` read them as aliases for -`ETHEREUM_RPC_URL` / `SOLANA_RPC_URL`, so leaving one exported points everyday calls at that endpoint too. -Profiles live at `$ALEO_BRIDGE_HOME` or `~/.aleo-bridge` and hold only the Aleo key (mode 600). - -## Tests - - cd bridge-sdk && .venv/bin/python -m pytest -q # hermetic - BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live -q # read-only mainnet checks - BRIDGE_LIVE_READS=1 BRIDGE_LIVE_SIMULATE=1 .venv/bin/python -m pytest -m live tests/live -q - -Literals and vectors: `docs/veil-brief.md`. - -## Live tests - -The funded suite (`tests/live/`, ported from veil's `test/integration/live/`) moves real money. It -is off unless you turn it on, in your own shell, one command at a time. +Move assets between **Aleo**, **Ethereum** and **Solana** from Python, over the +reviewed Hyperlane warp routes (ETH, WBTC, USDT, SOL) and Circle xReserve +(USDC ↔ USDCx). Web3.py idioms on top of the `aleo` facade: reads return +values, writes return prepared calls, the lifecycle is `quote → execute → +wait`, and `recover` / `resume` / `complete` pick up wherever a process died. +It is a port of veil's `@provablehq/aleo-bridge-sdk` 0.1.0 (registry +`2026-08-31.solana-deposits.1`) into the web3.py-style verb structure of +`aleo-sdk`. + +```sh +pip install aleo-bridge-sdk # Aleo legs only +pip install 'aleo-bridge-sdk[evm]' # + Ethereum (web3, eth-account) +pip install 'aleo-bridge-sdk[solana]' # + Solana (solders, solana) +pip install 'aleo-bridge-sdk[evm,solana]' # everything +``` + +Import name: `aleo_bridge`. + +## Quick start + +```python +from aleo_bridge import Bridge + +bridge = Bridge.from_env() # keys + RPCs from the environment (table below) +print(bridge.status()) # addresses, balances of every bridge asset, pending transfers + +quote = bridge.quote("ethereum/wbtc", "aleo/wbtc", amount="0.001", recipient=bridge.aleo_address()) +print(quote.kind, quote.fees, quote.amount_out) # show fees + amount_out before moving anything + +progress = bridge.execute(quote.plan) # approval (if needed) + dispatch; checkpoints saved +progress = bridge.wait(progress) # stops at resume / complete / done / failed +if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) +if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce="…")) +assert progress.next == "done", progress.error +``` + +`Bridge.from_profile()` instead keeps an Aleo key and a checkpoint store under +`~/.aleo-bridge/` (`$ALEO_BRIDGE_HOME`), created on first use — EVM/Solana keys +still come from the arguments or the environment and are never written to disk. + +## Connections + +```python +from aleo import Aleo, HTTPProvider +from aleo_bridge import Bridge, Ethereum, Solana + +aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="mainnet")) +aleo.default_account = aleo.account.from_private_key(aleo_key) + +bridge = Bridge(aleo) # Aleo legs only +bridge = Bridge(aleo, ethereum=Ethereum(ETH_RPC, private_key=evm_key), + solana=Solana(SOL_RPC, private_key=sol_key)) # raw keys +bridge = Bridge(aleo, ethereum=Ethereum(w3=my_w3, signer=my_local_account), + solana=Solana(client=my_client, signer=my_keypair)) # configured signers +bridge = Bridge(aleo, ethereum=my_w3, solana=my_client) # bare clients: read-only +``` + +| `Ethereum(...)` | Transport | Signer | +| --- | --- | --- | +| `rpc_url` + `private_key` / `signer` | `Web3(HTTPProvider(rpc_url))` | the key or `LocalAccount` | +| `w3` + `private_key` / `signer` | your `Web3` (middleware, PoA, retries) | the key or `LocalAccount` | +| `w3` alone | your `Web3` | `w3.eth.default_account` via your signing middleware, else read-only | -**Gates** (read only — nothing in this repository sets them; the exact acknowledgement strings are -the constants in `tests/live/config.py`): +| `Solana(...)` | Transport | Signer | +| --- | --- | --- | +| `rpc_url` (default `api.mainnet-beta.solana.com`) + `private_key` / `signer` | the SDK's own synchronous `SolanaRpcClient(rpc_url, commitment="confirmed")` — a `requests`-based JSON-RPC client, since solana-py ≥ 0.36 ships only an async client | base58 (Phantom export) / the 64-int `id.json` array, a solders `Keypair`, or any solana-py `Signer` | +| `client` + `private_key` / `signer` | your client reused: a `SolanaRpcClient`, a solana-py `AsyncClient` (adapted internally through `_AsyncClientAdapter`), or any duck-typed object with the same RPC methods | as above | +| `client` alone | your client | read-only | + +`Solana` supports `close()` and use as a context manager (`with Solana(...) as +solana:`) to release the wrapped client's resources; `__exit__` swallows +whatever `close()` raises, so call it directly if you need to see the error. +`Bridge.from_env()` reads `EVM_PRIVATE_KEY` + `ETHEREUM_RPC_URL` and +`SOLANA_PRIVATE_KEY` (+ `SOLANA_RPC_URL`) — see the aliases and precedence +notes in the environment table below. + +## Routes + +Assets are `"chain/key"` (`"ethereum/usdc"`, `"aleo/usdcx"`); `bridge.registry` +lists everything. Active mainnet routes: + +| Route | Protocol | Minimum | Notes | +| --- | --- | --- | --- | +| `xreserve:ethereum/usdc->aleo/usdcx` | Circle xReserve | 2 USDC | `mint_mode` public / record / **private** (you finish with `complete`) | +| `xreserve:aleo/usdcx->ethereum/usdc` | Circle xReserve | > 2 USDCx | 2 USDCx withdrawal fee; private burn (default) needs a record + exclusion proof — computed for you | +| `hyperlane:ethereum/eth->aleo/eth` / reverse | Hyperlane (native) | 1 wei | `msg.value` carries ETH + relayer fee | +| `hyperlane:ethereum/wbtc->aleo/wbtc` / reverse | Hyperlane (collateral) | 1 sat | approval + dispatch | +| `hyperlane:ethereum/usdt->aleo/usdt` / reverse | Hyperlane (collateral) | 1 µUSDT | approval reset to 0 first (USDT) | +| `hyperlane:solana/sol->aleo/sol` / `hyperlane:aleo/sol->solana/sol` | Hyperlane | 1 lamport | IGP + rent quoted live | + +Testnet: `xreserve:sepolia/usdc->aleo-testnet/usdcx` and its reverse. ALEO and +USAD routes, plus every `base`/`hyperevm` route, are `metadata-required`: +listed, refused by `quote`/`execute` until their deployments are reviewed +upstream. + +Aleo-origin Hyperlane transfers spend PUBLIC balances (`unshield` first); +Hyperlane delivers into public balances (`shield` afterwards if you want). + +**Registry fee vs. live fee — a known discrepancy.** The registry's +`xreserve:*usdcx->*usdc` literal is `withdrawalFeeAtomic = 2_000_000` (2 +USDCx), and `quote` promises exactly that. The 2026-09-18 testnet round trip +(next section) actually delivered a live xReserve withdrawal fee of +**≈1.0035 USDC**, not 2 USDC — Circle's fee is evidently dynamic and the +registry literal has not been re-measured against it. This SDK does not +change the registry literal or the quote; `amount_out` is a quote, not a +guarantee, and the same literal is used on the mainnet route, so budget for +the same gap there until someone re-measures it live. + +## The lifecycle + +```python +quote = bridge.quote(source, destination, amount="…" | amount_atomic=…, recipient=…, + sender=None, protocol=None, mint_mode="public", secret_nonce="0scalar") +progress = bridge.execute(quote.plan, on_checkpoint=save, proving="delegate", mode=None, + record=None, merkle_proof=None, gas_payment_microcredits=None, + secret_nonce=None, poll_seconds=1.0, timeout_seconds=120.0) +progress = bridge.wait(progress, until=None, poll_seconds=15.0, timeout_seconds=1200.0, on_update=None) +receipt = bridge.get_status(plan, receipt) # one refresh, no polling +progress = bridge.recover(checkpoint) # reads only +progress = bridge.resume(progress, on_checkpoint=save) +progress = bridge.complete(progress, secret_nonce="…", on_checkpoint=save) +bridge.pending() # recover() every stored checkpoint +``` + +`execute` emits a `Checkpoint` at every boundary: after each approval hash, +after proving and **before** broadcast for Aleo legs, and after broadcast. +Aleo legs prove through the delegated prover (`proving="delegate"`) or locally +(`proving="local"`) and are broadcast from the checkpointed bytes, so a crash +between proving and broadcast is resumable without proving twice. Every EVM +and Solana write checkpoints its own hash **before** polling for the receipt, +so a crash mid-poll never re-signs; `send()` is single-use per transfer — +never call it twice for the same plan, recover from the checkpoint instead. + +| `progress.next` | Status | Meaning | You do | +| --- | --- | --- | --- | +| `wait` | source confirming / attestation pending / delivery pending | in flight | `wait(progress)` | +| `resume` | `SOURCE_SUBMISSION_PENDING` | approval confirmed or proof built, transfer not submitted | `resume(progress)` | +| `complete` | `DESTINATION_ACTION_REQUIRED` | Circle attested; your private mint needs your signature | `complete(progress, secret_nonce=…)` | +| `done` | `COMPLETED` | delivered (destination Mailbox / nullifier / balance verified) | nothing | +| `failed` | `FAILED` / `EXPIRED` | see `progress.error` | new quote | + +A `PollingTimeoutError` from `wait` is **not** a failure: the transfer is still +in flight (`exc.progress`); call `wait` again or `recover` later. This holds +for every leg — Ethereum, Solana and Aleo receipts all return "pending", never +raise, on a polling timeout; a receipt timeout is never treated as a delivery +failure. Once the deposit / dispatch / burn is broadcast, never call `execute` +(or `send()`/`build()` again for the same call) for the same transfer — +recover from the checkpoint. + +## Recovery + +```python +from aleo_bridge import FileCheckpointStore +bridge = Bridge.from_env(checkpoints=FileCheckpointStore("~/.aleo-bridge/checkpoints")) # or BRIDGE_CHECKPOINT_DIR +for progress in bridge.pending(): # after a restart + if progress.next == "wait": progress = bridge.wait(progress) + if progress.next == "resume": progress = bridge.wait(bridge.resume(progress)) + if progress.next == "complete": progress = bridge.wait(bridge.complete(progress, secret_nonce=my_nonce)) +``` + +Checkpoints are an allowlist (version 1): intent, route id + registry version, +transaction ids, the proved Aleo transaction, the Solana blockhash lifetime and +the delivery baseline. Never keys, record plaintext, `secret_nonce`, +attestations, payloads or hashes. `recover` re-resolves the route from the live +registry and refuses a registry-version mismatch. Idempotent rebroadcast: a +duplicate-transaction response from the node is success. `FileCheckpointStore` +writes mode-600 files via an atomic rename; profile checkpoints (under +`~/.aleo-bridge/`) get the same treatment. `Bridge(checkpoints=store)` also +auto-saves through every lifecycle call, the same channel Ethereum, Solana and +Aleo writes all use — you don't have to pass `on_checkpoint=` yourself unless +you want a second sink. + +## Private USDCx mints + +`mint_mode="private"` commits `(recipient, secret_nonce)` on Ethereum; only the +recipient's Aleo key can `complete` the mint, with the same `secret_nonce`. The +SDK never stores the nonce (default `0scalar`). Public and record mints are +relayer-driven and finish at `done` when the bridge nullifier confirms delivery. + +## shield / unshield + +```python +bridge.shield("aleo/eth", amount="0.001").delegate() # public balance → private record (ARC-20) +bridge.unshield("aleo/usdcx", amount="5").delegate() # ARC-22: record + freeze-list exclusion proof, computed for you +bridge.freezelist.exclusion_proof(address, "usdcx_stablecoin.aleo") # the `[MerkleProof; 2]` literal itself +``` + +## Tier 2 modules + +`bridge.hyperlane.transfer_remote / quote_gas_payment / is_delivered`, +`bridge.xreserve.burn / private_mint / get_attestation / is_delivered / hook_data`, +`bridge.eth.transfer_remote / deposit_usdc / quote_transfer_remote / quote_deposit_usdc / balance / is_delivered +/ source_status / recover_source`, +`bridge.sol.transfer_remote / quote_transfer_remote / balance`. Aleo writes return an +`AleoCall` (`simulate() / prove() / delegate_prepared() / submit_prepared() / +transact() / delegate()`); EVM and Solana writes return `EvmCall` / `SolCall` +(`build()` → unsigned, `send()`). `python -m aleo_bridge` prints the full +generated reference (`AGENTS.md`). + +### Ethereum, directly + +```python +quote = bridge.eth.quote_transfer_remote("wbtc", aleo_recipient, amount="0.001") +print(quote.native_fee_atomic, quote.approval_required) + +call = bridge.eth.transfer_remote("wbtc", aleo_recipient, amount="0.001") +call.build() # unsigned tx dicts: approve(s) then transferRemote +result = call.send(on_checkpoint=store.save) # approvals → dispatch; each hash checkpointed before polling +result.message_id, result.receipt.status # Hyperlane message id, DELIVERY_PENDING + +usdc_quote = bridge.eth.quote_deposit_usdc(aleo_recipient, amount="2", mint_mode="public") +deposit = bridge.eth.deposit_usdc(aleo_recipient, amount="2", mint_mode="public").send() +deposit.message_hash # Circle attestation lookup key (receipt id), ATTESTATION_PENDING + +bridge.eth.balance("eth"); bridge.eth.is_delivered(message_id) # reads +bridge.eth.source_status(plan, receipt) # one refresh of an approval/confirming receipt +bridge.eth.recover_source(plan, checkpoint) # log-scan recovery, never signs +``` + +Routes: ETH (native), WBTC and USDT (collateral; USDT resets a non-zero +allowance to 0 first) via Hyperlane; USDC → USDCx via Circle xReserve (2 USDC +minimum, `mint_mode` public/record/private — private deposits go to the +shielded wrapper program and need the same `secret_nonce` at `complete` time; +the SDK never stores it). A receipt timeout returns a pending receipt, never a +failure. Live checks: `BRIDGE_LIVE_READS=1 ETHEREUM_RPC_URL=…` for read-only +mainnet quotes (`tests/live/test_eth_reads.py`); `BRIDGE_LIVE_FUNDS=1 +BRIDGE_LIVE_STATE_DIR=… SEPOLIA_RPC_URL=… EVM_PRIVATE_KEY=… +ALEO_E2E_PRIVATE_KEY=…` for the 2 USDC Sepolia leg +(`tests/live/test_eth_sepolia_leg1.py`). + +### Solana, directly + +```python +quote = bridge.sol.quote_transfer_remote(aleo_addr, amount="0.01") # amount + IGP + fee + rent, in lamports +call = bridge.sol.transfer_remote(aleo_addr, amount="0.01") +tx = call.build() # VersionedTransaction, unique-message key signed +result = call.send(on_checkpoint=store.save) # fee-payer signature, broadcast, poll to confirmed +result.message_id # Hyperlane message id from the Mailbox log +``` + +`private_key` accepts a base58 secret (Phantom export) or the 64-int JSON +array of a solana-cli `id.json`. Every read uses confirmed commitment; the +transaction sets a 400,000 compute-unit limit; the `SOURCE_CONFIRMING` receipt +(signature, unique-message address, blockhash, last valid block height) is +checkpointed before polling, and a polling timeout returns the pending receipt +rather than failing. The instruction encoding and account list are pinned +byte-for-byte against a recorded mainnet transfer +(`tests/fixtures/sealevel-transfer-remote.json`). + +Live read-only checks (no key, no funds): +`BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live/test_sol_reads.py -q -s` +decodes the live IGP account and prints a leg-11 quote for a pinned sender; +`SOLANA_RPC_URL` overrides the public default if it rate-limits. The funded +round trip runs from `scripts/rehearse.py` (see "Live rehearsal" below). + +## Agents and MCP + +```python +from aleo_bridge import bridge_tools, dispatch_tool +tools = bridge_tools() # Claude `tools=` shape; bridge_tools(include_writes=False) for read-only +dispatch_tool(bridge, "bridge_quote", {"source": "ethereum/usdc", "destination": "aleo/usdcx", "amount": "2", "recipient": addr}) +``` + +Reads: `bridge_status`, `bridge_list_assets`, `bridge_list_routes`, `bridge_quote`, +`bridge_get_progress`, `bridge_pending`. Writes (`bridge_execute`, `bridge_resume`, +`bridge_complete`, `bridge_shield`, `bridge_unshield`) require `confirm: true` — +without it they return the quote (or recovered progress / built call) plus +`how_to_confirm`, and move nothing. `bridge_execute` takes the quote inputs and +re-quotes internally, so agents never carry plans. +`python -m aleo_bridge.mcp` serves the same tools over stdio (`[mcp]` extra). + +## Live tests and rehearsal + +The funded suite (`tests/live/`, ported from veil's `test/integration/live/`) +moves real money. It is off unless you turn it on, in your own shell, one +command at a time. `scripts/rehearse.py` drives the same case functions the +live pytest suite uses, over one route or a whole case, at minimum amounts, +and backs both the CLI and `-m live` so they cannot drift. + +**The testnet round trip has run for real, end to end, both directions** +(2026-09-18): Sepolia deposit of 3 USDC +(`0x08cd56e4a10c84d62ee000ceb20d854d2f1b6b9a6102db8f1414abfb871faab4`) into a +private USDCx mint on aleo-testnet +(`at1jdy6wyal4ndwwydy80hjxt5q9eh332vk6t8zrw8jsvgc0pg7jcxqc85day`), then a +return burn +(`at1cufuy7v4lc4dn5ek3vdfa0tpeh8rrqqnahyelv06d2ll5n0kvczsrv9ugq`) delivering a +Sepolia withdrawal +(`0xbacb3ff270f6cee401cf2aa9ce6ed3be3b9d0426b18c04a2674c86dca11e4b54`). +Recovery from the on-disk checkpoint was exercised on both legs — the process +that submitted was discarded and a fresh `Bridge` over the same +`FileCheckpointStore` finished each transfer from `bridge.pending()`. The +observed withdrawal fee (see "Registry fee vs. live fee" above) is the one +concrete correction those runs produced. Every mainnet fund-moving case has +been quoted and prechecked live (funding table below) but still runs +quote-only — mainnet execution needs the operator to fund the bridge wallets +first, which has not happened yet; this README does not carry wallet +addresses or balances. + +**Gates** (read only — nothing in this repository sets them; the exact +acknowledgement strings are the constants in `tests/live/config.py`): | Variable | Effect | | --- | --- | @@ -134,25 +307,29 @@ the constants in `tests/live/config.py`): | `BRIDGE_LIVE_MAINNET_ACK=…` + `BRIDGE_LIVE_MAINNET_CASES=` | the named mainnet cases may run | | `BRIDGE_LIVE_MAINNET_EXECUTE=…` | the wallet may actually submit | -Without the last one every case runs to the quote, prints the route/amount/fee table and returns — -that is the default, and it is how you rehearse. Keys and endpoints come from `Bridge.from_env()` -(`BRIDGE_PRIVATE_KEY`, `EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`/ +Without the last one every case runs to the quote, prints the route/amount/fee +table and returns — that is the default, and it is how you rehearse. Keys and +endpoints come from `Bridge.from_env()` (`BRIDGE_PRIVATE_KEY`, +`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`/ `BRIDGE_SOLANA_PRIVATE_KEY`, `ETHEREUM_RPC_URL`/`BRIDGE_LIVE_ETHEREUM_RPC_URL`, -`SOLANA_RPC_URL`/`BRIDGE_LIVE_SOLANA_RPC_URL`); recipients default to your own addresses and can be -overridden with `BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT` / `BRIDGE_LIVE_EVM_RECIPIENT` / -`BRIDGE_LIVE_SOLANA_RECIPIENT`. - -**State.** Each case keeps one file at `$BRIDGE_LIVE_STATE_DIR//-.json` -(mode 600) holding the checkpoint, the source/destination transaction ids and the message id; the -private-mint secret nonce lives beside it in `.secret` (mode 600, created exclusively) and -never in the state, a log or a checkpoint. Re-running a case resumes from that file — recover -first, then `wait`/`resume`/`complete`; a completed case re-asserts what it recorded and exits. -A timeout is *pending*, not a failure: the checkpoint stays on disk and the run prints the -`--recover` command. - -**Cases and routes.** Five cases, each parametrized over every registry route it covers — both -directions are separate cases, and a route with `availability != "active"` is reported as -skipped-by-registry rather than dropped: +`SOLANA_RPC_URL`/`BRIDGE_LIVE_SOLANA_RPC_URL`); recipients default to your own +addresses and can be overridden with `BRIDGE_LIVE_ALEO_MAINNET_RECIPIENT` / +`BRIDGE_LIVE_EVM_RECIPIENT` / `BRIDGE_LIVE_SOLANA_RECIPIENT`. + +**State.** Each case keeps one file at +`$BRIDGE_LIVE_STATE_DIR//-.json` (mode 600) holding +the checkpoint, the source/destination transaction ids and the message id; +the private-mint secret nonce lives beside it in `.secret` (mode 600, +created exclusively) and never in the state, a log or a checkpoint. +Re-running a case resumes from that file — recover first, then +`wait`/`resume`/`complete`; a completed case re-asserts what it recorded and +exits. A timeout is *pending*, not a failure: the checkpoint stays on disk +and the run prints the `--recover` command. + +**Cases and routes.** Five cases, each parametrized over every registry route +it covers — both directions are separate cases, and a route with +`availability != "active"` is reported as skipped-by-registry rather than +dropped: | Case | Routes | Amount | | --- | --- | --- | @@ -162,67 +339,125 @@ skipped-by-registry rather than dropped: | `evm-xreserve` | ethereum USDC → aleo USDCx | `2` USDC, private mint (needs `complete`) | | `aleo-xreserve` | aleo USDCx → ethereum USDC | `2.000001` USDCx private burn, delivers 1 atomic unit | -The `metadata-required` mainnet routes (ALEO on ethereum/solana/base/hyperevm, USAD) are -parametrized too and skip with `registry:metadata-required` — `tests/live/test_lifecycle_live.py` -builds its parameters by enumerating `DEFAULT_REGISTRY.routes(...)`, so a route that no case covers -raises at import rather than disappearing. The same two xReserve case functions run the testnet -pair (`xreserve:sepolia/usdc->aleo-testnet/usdcx` at `3` USDC, then -`xreserve:aleo-testnet/usdcx->sepolia/usdc` at `2.000001`); the testnet keys and RPC are -`BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`/`ALEO_E2E_PRIVATE_KEY`, -`BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY`/`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY` and -`SEPOLIA_RPC_URL`/`BRIDGE_LIVE_SEPOLIA_RPC_URL` (public default when unset). Testnet needs no -mainnet acknowledgement — the funds gate alone. - -**Recovery is not simulated.** Every funded test runs in two phases: the first quotes, prechecks -and calls `execute` once, then returns; the client that executed is discarded, and a brand-new -`Bridge` over the same `FileCheckpointStore` finishes the transfer from `bridge.pending()` → -`bridge.recover(checkpoint)` → `wait`/`resume`/`complete`. `execute` is never called twice for one -transfer, and `BRIDGE_LIVE_XRESERVE_AMOUNT` (or `BRIDGE_LIVE__AMOUNT`) overrides an amount. - -**Funding per run** (from the 2026-09-17 read-only mainnet quote sweep): ETH/WBTC/USDT Hyperlane -deposits cost ≈0.0000838 ETH each in native Hyperlane fees plus L1 gas (USDT also needs one -approval); the Aleo-origin legs cost 8.174147 (ETH), 9.138947 (WBTC), 9.138947 (USDT) and 7.661056 -(SOL) credits in IGP payment plus the Aleo transaction fee, and need the asset's public balance on -Aleo; solana → aleo costs 5,647,521 lamports all-in; the xReserve deposit needs 2 USDC plus gas, -and the burn needs an unspent private USDCx record of at least 2.000001. Return legs spend what -the matching inbound leg minted, so run inbound first — an unfunded return leg skips with its +The `metadata-required` mainnet routes (ALEO on ethereum/solana/base/hyperevm, +USAD) are parametrized too and skip with `registry:metadata-required` — +`tests/live/test_lifecycle_live.py` builds its parameters by enumerating +`DEFAULT_REGISTRY.routes(...)`, so a route that no case covers raises at +import rather than disappearing. The same two xReserve case functions run the +testnet pair (`xreserve:sepolia/usdc->aleo-testnet/usdcx` at `3` USDC, then +`xreserve:aleo-testnet/usdcx->sepolia/usdc` at `2.000001`); the testnet keys +and RPC are `BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`/`ALEO_E2E_PRIVATE_KEY`, +`BRIDGE_LIVE_EVM_TESTNET_PRIVATE_KEY`/`EVM_PRIVATE_KEY`/`BRIDGE_EVM_PRIVATE_KEY` +and `SEPOLIA_RPC_URL`/`BRIDGE_LIVE_SEPOLIA_RPC_URL` (public default when +unset). Testnet needs no mainnet acknowledgement — the funds gate alone. + +**Recovery is not simulated.** Every funded test runs in two phases: the +first quotes, prechecks and calls `execute` once, then returns; the client +that executed is discarded, and a brand-new `Bridge` over the same +`FileCheckpointStore` finishes the transfer from `bridge.pending()` → +`bridge.recover(checkpoint)` → `wait`/`resume`/`complete`. `execute` is never +called twice for one transfer, and `BRIDGE_LIVE_XRESERVE_AMOUNT` (or +`BRIDGE_LIVE__AMOUNT`) overrides an amount. + +**Funding per run** (from the 2026-09-17 read-only mainnet quote sweep): +ETH/WBTC/USDT Hyperlane deposits cost ≈0.0000838 ETH each in native Hyperlane +fees plus L1 gas (USDT also needs one approval); the Aleo-origin legs cost +8.174147 (ETH), 9.138947 (WBTC), 9.138947 (USDT) and 7.661056 (SOL) credits in +IGP payment plus the Aleo transaction fee, and need the asset's public +balance on Aleo; solana → aleo costs 5,647,521 lamports all-in; the xReserve +deposit needs 2 USDC plus gas, and the burn needs an unspent private USDCx +record of at least 2.000001. Return legs spend what the matching inbound leg +minted, so run inbound first — an unfunded return leg skips with its shortfall printed rather than failing. -**Running it.** *Quote only* — prices and prechecks every route and submits nothing, whatever is -acknowledged: +**Running it.** *Quote only* — prices and prechecks every route and submits +nothing, whatever is acknowledged: + +```sh +python scripts/rehearse.py --case evm-hyperlane --quote-only +python scripts/rehearse.py --case evm-xreserve --report run.json # submits only if acknowledged +``` + +*Testnet* (Sepolia ⇄ aleo-testnet, the funds gate only — no mainnet +acknowledgement): + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_evm_xreserve_deposit +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_aleo_xreserve_return +``` + +*Mainnet, quote only* — the acknowledgement that names the cases, and +deliberately no execute variable, so each route is priced and prechecked and +nothing is signed: + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES=evm-hyperlane,evm-xreserve,aleo-hyperlane,aleo-xreserve,solana-hyperlane \ + pytest -m live -s tests/live/test_lifecycle_live.py +``` + +*Mainnet, for real* — **you** type this, in your own shell, for one command; +both acknowledgement values are the constants in `tests/live/config.py` and +appear nowhere in this repository in a copy-pasteable form. Nothing in the +suite or the CLI ever sets them: + +```sh +BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ + BRIDGE_LIVE_MAINNET_ACK= \ + BRIDGE_LIVE_MAINNET_CASES= \ + BRIDGE_LIVE_MAINNET_EXECUTE= \ + pytest -m live -s "tests/live/test_lifecycle_live.py::test_evm_xreserve" +``` - python scripts/rehearse.py --case evm-hyperlane --quote-only - python scripts/rehearse.py --case evm-xreserve --report run.json # submits only if acknowledged +*Resuming* an interrupted transfer (the run prints this line itself): -*Testnet* (Sepolia ⇄ aleo-testnet, the funds gate only — no mainnet acknowledgement): +```sh +python scripts/rehearse.py --recover "$BRIDGE_LIVE_STATE_DIR/mainnet/-.json" +``` - BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ - pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_evm_xreserve_deposit - BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ - pytest -m live -s tests/live/test_lifecycle_live.py::test_testnet_aleo_xreserve_return +Exit codes: `0` ok, `1` a case failed, `2` something is still pending. The +harness itself (route selection, state paths, the exit-code table) is +covered hermetically by `tests/test_live_helpers.py`. -*Mainnet, quote only* — the acknowledgement that names the cases, and deliberately no execute -variable, so each route is priced and prechecked and nothing is signed: +## Environment variables - BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ - BRIDGE_LIVE_MAINNET_ACK= \ - BRIDGE_LIVE_MAINNET_CASES=evm-hyperlane,evm-xreserve,aleo-hyperlane,aleo-xreserve,solana-hyperlane \ - pytest -m live -s tests/live/test_lifecycle_live.py +| Variable | Used by | Meaning | +| --- | --- | --- | +| `BRIDGE_PRIVATE_KEY` | `from_env`, `from_profile` (import), MCP | Aleo private key (`APrivateKey1…`), **required** by `from_env` | +| `ALEO_ENDPOINT` | `from_env` | node API origin (default `https://edge.provable.com/api`) | +| `ALEO_NETWORK` | `from_env` | `mainnet` (default) or `testnet` | +| `ALEO_API_KEY`, `ALEO_CONSUMER_ID` | `from_env` | optional Provable credentials for legacy endpoints | +| `EVM_PRIVATE_KEY`, `ETHEREUM_RPC_URL` | `from_env`, rehearsal | Ethereum signer + RPC (both or neither); aliases `BRIDGE_EVM_PRIVATE_KEY` / `BRIDGE_LIVE_ETHEREUM_RPC_URL` — used by the user's live shell/veil config, and NOT live-test-only: ordinary `Ethereum.from_env()` reads them too, so leaving one exported points everyday calls at that endpoint; the primary variable wins when both are set | +| `SOLANA_PRIVATE_KEY`, `SOLANA_RPC_URL` | `from_env`, rehearsal | Solana signer (base58 or `id.json` array) + RPC (optional); aliases `BRIDGE_SOLANA_PRIVATE_KEY` / `BRIDGE_LIVE_SOLANA_RPC_URL`, same precedence and same everyday-call caveat as the Ethereum pair | +| `BRIDGE_CHECKPOINT_DIR` | `from_env` | bind a `FileCheckpointStore` | +| `ALEO_BRIDGE_HOME` | `from_profile` | profile directory (default `~/.aleo-bridge/`), holds only the Aleo key, mode 600 | +| `ALEO_E2E_PRIVATE_KEY` | live tests / rehearsal, testnet | testnet Aleo key (alias `BRIDGE_LIVE_ALEO_TESTNET_PRIVATE_KEY`) | +| `BRIDGE_LIVE_FUNDS`, `BRIDGE_LIVE_STATE_DIR` | live tests, rehearsal | `1` + a directory outside the repo — funded cases exist at all | +| `BRIDGE_LIVE_MAINNET_ACK`, `BRIDGE_LIVE_MAINNET_CASES` | live tests, rehearsal | `I_ACKNOWLEDGE_BRIDGE_MAINNET_FUNDS` (see `tests/live/config.py`) + `leg-3,leg-5,…` — the named mainnet cases may run | +| `BRIDGE_LIVE_MAINNET_EXECUTE` | live tests, rehearsal | `I_ACKNOWLEDGE_THIS_SUBMITS_MAINNET_TRANSACTIONS` — without it every case quotes and prechecks only | + +Gates and acknowledgement strings live only in `tests/live/config.py` — nothing +in this repository sets them, and they never appear here in copy-pasteable +form. -*Mainnet, for real* — **you** type this, in your own shell, for one command; both acknowledgement -values are the constants in `tests/live/config.py` and appear nowhere in this repository in a -copy-pasteable form. Nothing in the suite or the CLI ever sets them: +## Tests - BRIDGE_LIVE_FUNDS=1 BRIDGE_LIVE_STATE_DIR="$HOME/.bridge-live" \ - BRIDGE_LIVE_MAINNET_ACK= \ - BRIDGE_LIVE_MAINNET_CASES= \ - BRIDGE_LIVE_MAINNET_EXECUTE= \ - pytest -m live -s "tests/live/test_lifecycle_live.py::test_evm_xreserve" +```sh +cd bridge-sdk && .venv/bin/python -m pytest -q # hermetic +.venv/bin/python -m pytest -q -m "not live" # same set, explicit marker +BRIDGE_LIVE_READS=1 .venv/bin/python -m pytest -m live tests/live -q # read-only mainnet checks +BRIDGE_LIVE_READS=1 BRIDGE_LIVE_SIMULATE=1 .venv/bin/python -m pytest -m live tests/live -q +``` -*Resuming* an interrupted transfer (the run prints this line itself): +Literals and vectors: `docs/veil-brief.md`. - python scripts/rehearse.py --recover "$BRIDGE_LIVE_STATE_DIR/mainnet/-.json" +## Development -Exit codes: `0` ok, `1` a case failed, `2` something is still pending. The same case functions back -the pytest suite (`-m live`), so the CLI and the tests cannot drift. The harness itself is covered -hermetically by `tests/test_live_helpers.py`. +```sh +cd bridge-sdk && python -m venv .venv && .venv/bin/pip install -e '.[dev]' +.venv/bin/python -m pytest -q # unit + mocked integration +.venv/bin/python codegen/gen_context.py --check # AGENTS.md is generated from docstrings +``` diff --git a/bridge-sdk/tests/test_package.py b/bridge-sdk/tests/test_package.py index 1600b2fd..9fc71579 100644 --- a/bridge-sdk/tests/test_package.py +++ b/bridge-sdk/tests/test_package.py @@ -1,8 +1,53 @@ import importlib +import re import sys +import tomllib +from pathlib import Path import pytest +import aleo_bridge + +ROOT = Path(__file__).resolve().parents[1] + + +def test_version_is_pinned_in_lockstep(): + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert pyproject["project"]["version"] == "0.1.0" == aleo_bridge.__version__ + assert pyproject["project"]["name"] == "aleo-bridge-sdk" + extras = pyproject["project"]["optional-dependencies"] + assert {"evm", "solana", "mcp", "dev"} <= set(extras) + assert any(dep.startswith("mcp>=1") and "<2" in dep for dep in extras["mcp"]) + + +def test_wheel_ships_agents_md(): + assert (ROOT / "python" / "aleo_bridge" / "AGENTS.md").exists() + assert aleo_bridge.agent_guide().startswith("# aleo-bridge") + + +def test_readme_covers_the_journey(): + readme = (ROOT / "README.md").read_text() + for needle in ("Bridge.from_env()", "Bridge.from_profile()", "Ethereum(", "Solana(", "progress.next", + "recover", "resume", "complete", "shield", "unshield", "python -m aleo_bridge.mcp", + "scripts/rehearse.py", "BRIDGE_PRIVATE_KEY", "EVM_PRIVATE_KEY", "SOLANA_PRIVATE_KEY", + "BRIDGE_LIVE_MAINNET_EXECUTE", "secret_nonce", "| `resume` |", "| `complete` |"): + assert needle in readme, needle + assert "Co-Authored-By" not in readme + # every active mainnet route appears in the route table + for route_id in ("xreserve:ethereum/usdc->aleo/usdcx", "hyperlane:ethereum/eth->aleo/eth", + "hyperlane:aleo/sol->solana/sol"): + assert route_id in readme + + +def test_ci_has_bridge_jobs(): + workflow = (ROOT.parent / ".github" / "workflows" / "sdk-wheels.yml").read_text() + assert workflow.count("'bridge-sdk/**'") == 2 + for job in ("build-bridge:", "release-bridge:"): + assert job in workflow + assert "environment: pypi-bridge" in workflow + assert 'pip install "$(ls bridge-sdk/dist/*.whl)[evm,solana,mcp]"' in workflow + assert re.search(r'pip install "\$\(ls bridge-sdk/dist/\*\.whl\)"\s*\n\s*python -c "import aleo_bridge', workflow) + def test_import_without_optional_extras(monkeypatch): for mod in ("web3", "eth_account", "solders", "solana", "mcp"): From fa05da5266226b598aca54b3c0b8fdc0d16bc9c1 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 18 Sep 2026 16:38:22 -0400 Subject: [PATCH 94/94] ci: add build-bridge and release-bridge jobs for aleo-bridge-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/sdk-wheels.yml | 79 +++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index 834b3d7c..1b70bff1 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -6,6 +6,7 @@ on: - 'sdk/**' - 'sdk-abi/**' - 'shield-swap-sdk/**' + - 'bridge-sdk/**' - '.github/workflows/sdk-wheels.yml' - '.github/workflows/sdk.yml' branches: @@ -18,6 +19,7 @@ on: - 'sdk/**' - 'sdk-abi/**' - 'shield-swap-sdk/**' + - 'bridge-sdk/**' - '.github/workflows/sdk-wheels.yml' - '.github/workflows/sdk.yml' workflow_dispatch: @@ -271,17 +273,62 @@ jobs: name: shield-swap-wheels-universal path: shield-swap-sdk/dist + # aleo-bridge-sdk is pure Python (hatchling): one universal wheel + sdist. + # Tested against the built aleo-sdk wheel; installed WITH every extra for the + # suite and WITHOUT extras for the import smoke — an Aleo-only install must + # import (extras raise MissingExtraError at the point of use, never at import). + # Only the hermetic suite runs here (`-m "not live"`): no key, no live RPC. + build-bridge: + name: bridge-build + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + name: wheels-linux-x86_64 + path: sdk-dist + - name: Build wheel + sdist + run: | + pip install build + python -m build bridge-sdk --outdir bridge-sdk/dist + - name: Test against the built aleo-sdk wheel (all extras, hermetic only) + run: | + pip install sdk-dist/aleo_sdk-*.whl + pip install "$(ls bridge-sdk/dist/*.whl)[evm,solana,mcp]" pytest pytest-asyncio + cd bridge-sdk && python -m pytest -q -m "not live" + - name: AGENTS.md up to date + run: python bridge-sdk/codegen/gen_context.py --check + - name: Smoke test wheel with extras + run: | + cd "$RUNNER_TEMP" + python -c "import aleo_bridge, aleo_bridge.eth, aleo_bridge.sol, aleo_bridge.mcp; print('aleo-bridge-sdk', aleo_bridge.__version__)" + python -m aleo_bridge | head -1 + - name: Smoke test wheel WITHOUT extras + run: | + pip uninstall -y aleo-bridge-sdk web3 eth-account solders solana mcp + pip install "$(ls bridge-sdk/dist/*.whl)" + python -c "import aleo_bridge; from aleo_bridge import Bridge, Ethereum, Solana, DEFAULT_REGISTRY; print(len(DEFAULT_REGISTRY.routes(include_unavailable=True, environment=None)), 'routes')" + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: bridge-wheels-universal + path: bridge-sdk/dist + # Publishing uses PyPI trusted publishing (OIDC) — no token secret. Each # package has its own job because a pending publisher must be unique per # (repo, workflow, environment): the job's environment name must exactly - # match the publisher registered on PyPI for that project. All three gate - # on every build job, and they release strictly in dependency order: - # abi -> sdk -> shield-swap (shield-swap-sdk requires aleo-sdk on PyPI). + # match the publisher registered on PyPI for that project. All gate on + # every build job, and they release strictly in dependency order: + # abi -> sdk -> shield-swap / bridge (both require aleo-sdk on PyPI). release-abi: name: Release aleo-contract-abi-generator runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge] environment: pypi-abi permissions: id-token: write @@ -301,7 +348,7 @@ jobs: name: Release aleo-sdk runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, release-abi] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-abi] environment: pypi permissions: id-token: write @@ -321,7 +368,7 @@ jobs: name: Release shield-swap-sdk runs-on: ubuntu-latest if: "startsWith(github.ref, 'refs/tags/')" - needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, release-sdk] + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-sdk] environment: pypi-shield-swap permissions: id-token: write @@ -336,3 +383,23 @@ jobs: with: packages-dir: dist skip-existing: true + + release-bridge: + name: Release aleo-bridge-sdk + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [build, sdist, build-abi, sdist-abi, build-shield-swap, build-bridge, release-sdk] + environment: pypi-bridge + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: 'bridge-wheels-*' + merge-multiple: true + path: dist + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true