diff --git a/README.md b/README.md index bc5f8ee..abd54c1 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,51 @@ const claimMessageTx = await bridge.buildClaimMessageFromHash( ); ``` +#### Bridge Transaction Tracking + +```typescript +// Poll the aggkit bridge tracker for a single transaction's route/status, +// keyed by the SOURCE network id and the tx hash that created the bridge. +const trackingData = await aggregator.getBridgeTracking( + 11155111, // source network where the bridge tx occurred + '0xBridgeTxHash123456789012345678901234567890123456789012345678' +); +``` + +The aggkit tracker (`tracker/v1`) has no push/subscription transport — only +this REST lookup — so callers must poll. ~5s between calls is a good +default (matches the dev-ui consumer). Stop polling as soon as either +terminal condition is met: + +- `tracking_status === 'finished'`, or +- `tracking_status === 'error'` with `bridge_status: null` (the tracker gave + up resolving the bridge at all — distinct from a step-level error, which + reports `tracking_status: 'error'` too but with `bridge_status` populated + and is retried by the tracker on its own). + +Keep polling through any other non-terminal state, including a regression +back to `'registered'` with `all_steps: null` — the FIRST call for a given +`(networkId, txHash)` pair registers it with the tracker, and the tracker +is stateful with a bounded retention window (`RetentionPeriod`); if a +tracked-but-not-yet-terminal bridge is evicted, the next poll silently +re-registers it from scratch (`'registered'`, `all_steps: null` again) +rather than erroring. + +`tracking_status`, `bridge_type`, and each step's `status`/`step_name` ship +as bare string unions on the wire — not a numeric value with a `_string` +companion field, unlike `error_type` and certificate `status`, which do +keep the int + `_string` pair. See the `AggkitTrackingData` / +`AggkitBridgeStepPath` JSDoc in `src/aggkit/types.ts` for the full +wire-format reference. + +**Caveat ([agglayer/aggkit#1786](https://github.com/agglayer/aggkit/issues/1786), OPEN)**: +the tracker's `WaitingClaim` step routinely precedes actual claimability by +seconds to tens of seconds — it reflects only the tracker's own fast-path +read of the settlement tx's L1 receipt, not aggkit's separate bridge-service +L1-info-tree sync that a claim's proof fetch depends on. Gate claim-readiness +UX on your own check (e.g. the bridge-service's own status/proof +availability), not on the tracker reaching `WaitingClaim`. + ## ⚙️ Configuration ### SDK Configuration Options @@ -464,6 +509,7 @@ The SDK includes a comprehensive registry of popular networks: - **Ethereum Mainnet** (Chain ID: 1) - **Katana** (Chain ID: 747474) - **Sepolia Testnet** (Chain ID: 11155111) + Additional networks can be added via the `chains` configuration option. diff --git a/scripts/aggkit-smoke.ts b/scripts/aggkit-smoke.ts new file mode 100644 index 0000000..18866ba --- /dev/null +++ b/scripts/aggkit-smoke.ts @@ -0,0 +1,486 @@ +/** + * aggkit-smoke.ts + * + * Integration smoke test for the aggkit SDK code (`src/aggkit/*`) against a + * LIVE aggkit-proxy (or haproxy `/aggkitapi`) REST endpoint fronting the + * 2-L2 (L2-1/L2-2) devnet — an integration script, NOT a fixture-driven unit + * test. Exercises: per-network sync-status across {0,1,2}, a real + * address's multi-network activity fan-out (via the aggregator), a + * known-ground-truth CLAIMED check, an L2->L2 row's derived status, the + * destination-injected `getClaimInputs` roundtrip for both an L2->L2 and an + * L2->L1 deposit, token-mappings, and (optionally) the proxy-502 + * partial-failure path. + * + * Run: + * + * AGGKIT_URL=http://127.0.0.1: npx tsx scripts/aggkit-smoke.ts + * + * Re-resolve the base URL (ports are ephemeral): + * kurtosis port print cdk aggkit-proxy-001 rest # direct proxy port + * kurtosis port print cdk agglayer-dev-ui-proxy-002 http # haproxy (append /aggkitapi) + * + * Env vars: + * AGGKIT_URL required. aggkit-proxy REST base (WITHOUT + * /bridge/v1) — either the direct proxy port, or + * `/aggkitapi`. + * PROXY_MODE optional, default "true". Skips check 1 + * (getHealth) when true — aggkit-proxy has no + * root health route. + * Set to "false" to run against a single direct + * aggkit instance instead (pre-S8 behaviour). + * L2_NETWORK_IDS optional, default "1,2". Comma-separated L2 + * network ids configured on the aggregator, all + * pointed at AGGKIT_URL (one aggkit-proxy fronts + * every network, selected per-request by ?network_id=). + * FROM_ADDRESS optional, default the L2-1 test EOA + * (`0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107`) + * that sent one past round's L2-1->L2-2 and + * L2-1->L1 lifecycle deposits. + * NOTE: this default is ENCLAVE-SPECIFIC. Bridge + * history does not survive `kurtosis enclave rm`, + * so against any recreated enclave this address + * has no activity and sections 3/5/6/7 fail for + * want of data rather than for a real defect. + * Always pass FROM_ADDRESS explicitly when running + * against an enclave you did not originally + * generate traffic on. + * RUN_PARTIAL_FAILURE_TEST optional, default "false". When "true", runs + * the final section: stops `aggkit-002-bridge` + * via `kurtosis service stop cdk + * aggkit-002-bridge`, asserts `getActivity` + * resolves with the dead network reported in + * failedNetworks instead of rejecting, then restarts it. Off by default + * because it mutates the live enclave. + */ + +import { execSync } from 'node:child_process'; +import { AggkitBridgeClient } from '../src/aggkit/client'; +import { AggkitBridgeAggregator } from '../src/aggkit/aggregator'; + +const AGGKIT_URL = process.env['AGGKIT_URL']; +if (!AGGKIT_URL) { + console.error( + 'AGGKIT_URL env var is required (aggkit-proxy REST base, e.g. http://127.0.0.1:33042,\n' + + 'or /aggkitapi).\n' + + 'Re-resolve with: kurtosis port print cdk aggkit-proxy-001 rest' + ); + process.exit(1); +} + +const PROXY_MODE = (process.env['PROXY_MODE'] ?? 'true') !== 'false'; +const L2_NETWORK_IDS = (process.env['L2_NETWORK_IDS'] ?? '1,2') + .split(',') + .map((s) => Number(s.trim())); +const FROM_ADDRESS = + process.env['FROM_ADDRESS'] ?? '0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107'; +const RUN_PARTIAL_FAILURE_TEST = + process.env['RUN_PARTIAL_FAILURE_TEST'] === 'true'; + +let failures = 0; +function assert(cond: unknown, message: string): void { + if (!cond) { + failures++; + console.error(`FAIL: ${message}`); + } else { + console.log(`PASS: ${message}`); + } +} + +async function main(): Promise { + const primaryNetworkId = L2_NETWORK_IDS[0] as number; + const allNetworkIds = [0, ...L2_NETWORK_IDS]; + const clients = new Map(); + for (const networkId of allNetworkIds) { + clients.set( + networkId, + new AggkitBridgeClient({ baseUrl: AGGKIT_URL as string, networkId }) + ); + } + const aggregator = new AggkitBridgeAggregator({ + networks: Object.fromEntries( + L2_NETWORK_IDS.map((id) => [id, AGGKIT_URL as string]) + ), + }); + + function mustGetClient(networkId: number): AggkitBridgeClient { + const client = clients.get(networkId); + if (!client) { + throw new Error( + `aggkit-smoke: no client configured for network ${networkId}` + ); + } + return client; + } + + console.log( + `\naggkit-smoke: base=${AGGKIT_URL} proxyMode=${PROXY_MODE} networks=${JSON.stringify( + L2_NETWORK_IDS + )} fromAddress=${FROM_ADDRESS}` + ); + + console.log(`\n=== 1. Health (GET /) ===`); + if (PROXY_MODE) { + console.log( + 'SKIPPED — aggkit-proxy has no root health route (haproxy strips the ' + + '/aggkitapi prefix and aggkit-proxy only registers ANY /bridge/v1/*any ' + + '-> 404). getHealth() is unused by the aggregator/app and remains a ' + + 'direct-instance-only convenience.' + ); + } else { + const health = await mustGetClient(primaryNetworkId).getHealth(); + console.log(health); + assert(health.status === 'ok', 'health.status === "ok"'); + assert( + typeof health.version === 'string' && health.version.length > 0, + 'health.version is a non-empty string' + ); + } + + console.log( + `\n=== 2. Sync status across ALL networks {${allNetworkIds.join(', ')}} (GET /bridge/v1/sync-status?network_id=N) ===` + ); + for (const networkId of allNetworkIds) { + const sync = await mustGetClient(networkId).getSyncStatus(); + console.log(` network ${networkId}: ${JSON.stringify(sync)}`); + assert( + sync.l1_info.is_synced === true, + `network ${networkId}: l1_info.is_synced === true` + ); + assert( + sync.l2_info.is_synced === true, + `network ${networkId}: l2_info.is_synced === true` + ); + } + + console.log( + `\n=== 3. Full fan-out activity across {${L2_NETWORK_IDS.join(', ')}} (via AggkitBridgeAggregator.getActivity) ===` + ); + const activity = await aggregator.getActivity({ + fromAddress: FROM_ADDRESS, + pageSize: 50, + }); + console.log( + `activity.data.length=${activity.data.length} pagination=${JSON.stringify( + activity.pagination + )} failedNetworks=${JSON.stringify(activity.failedNetworks)}` + ); + assert( + activity.failedNetworks.length === 0, + 'no failed networks in the getActivity fan-out (healthy enclave)' + ); + if (activity.data.length === 0) { + // The default FROM_ADDRESS is the EOA that sent one specific past round's + // lifecycle deposits. Enclave state does NOT survive `kurtosis enclave rm`, + // so on any freshly recreated enclave that address has zero traffic and + // sections 3/5/6/7 all fail for want of data — indistinguishable, from the + // output alone, from a genuine SDK regression. Say so explicitly. + console.log( + `\n !! No bridge activity found for fromAddress=${FROM_ADDRESS}.\n` + + ` The default address is enclave-specific: it only has traffic on the\n` + + ` enclave that originally produced it. If this enclave was recreated,\n` + + ` re-run with FROM_ADDRESS set to a wallet that has actually bridged\n` + + ` here, e.g. the dev-ui E2E wallet:\n` + + ` FROM_ADDRESS=0x... AGGKIT_URL=${AGGKIT_URL} npx tsx scripts/aggkit-smoke.ts\n` + + ` The sections below will fail for lack of data, not necessarily\n` + + ` because the SDK is broken.` + ); + } + assert( + activity.data.length > 0, + 'getActivity returned at least one transaction for the real from_address' + ); + + const knownStatuses = [ + 'BRIDGED', + 'LEAF_INCLUDED', + 'READY_TO_CLAIM', + 'CLAIMED', + ]; + const statusCounts: Record = {}; + for (const tx of activity.data) { + statusCounts[tx.status] = (statusCounts[tx.status] ?? 0) + 1; + assert( + knownStatuses.includes(tx.status), + `tx bridgeHash=${tx.bridgeHash} status "${tx.status}" is one of the 4 known statuses` + ); + } + console.log('status distribution across fetched page:', statusCounts); + + console.log( + `\n=== 4. Full status derivation for a known-ground-truth deposit ===` + ); + const primaryClient = mustGetClient(primaryNetworkId); + // Ground truth built directly from raw client calls (bypassing the + // aggregator) so we know independently which bridge_hash is genuinely + // claimed before checking what the aggregator derives for it. + const rawL1OriginBridges = await primaryClient.getBridges({ + networkId: 0, + networkIds: [primaryNetworkId], + fromAddress: FROM_ADDRESS, + pageSize: 200, + }); + const rawL2Claims = await primaryClient.getClaims({ + networkId: primaryNetworkId, + pageSize: 200, + }); + const claimedGlobalIndexes = new Set( + rawL2Claims.claims.map((c) => c.global_index) + ); + const knownClaimedBridge = rawL1OriginBridges.bridges.find((b) => + claimedGlobalIndexes.has(b.global_index) + ); + if (knownClaimedBridge) { + console.log( + `ground truth: bridge_hash=${knownClaimedBridge.bridge_hash} deposit_count=${knownClaimedBridge.deposit_count} global_index=${knownClaimedBridge.global_index}` + ); + const txForBridge = activity.data.find( + (tx) => tx.bridgeHash === knownClaimedBridge.bridge_hash + ); + if (txForBridge) { + assert( + txForBridge.status === 'CLAIMED', + `known-claimed bridge (deposit_count=${knownClaimedBridge.deposit_count}) derived as CLAIMED via getActivity` + ); + assert( + txForBridge.claimTransactionHash !== undefined, + 'CLAIMED tx carries a claimTransactionHash' + ); + } else { + console.log( + `(bridge_hash=${knownClaimedBridge.bridge_hash} not present on the fetched activity page due to pagination/ordering — ` + + `falling back to a direct claims-map consistency check instead of the getActivity-level assertion)` + ); + assert( + claimedGlobalIndexes.has(knownClaimedBridge.global_index), + 'raw claims data confirms this global_index is claimed (independent of getActivity pagination)' + ); + } + } else { + console.log( + '(no L1->L2 claimed row found for this from_address on this network — skipping this ground-truth check)' + ); + } + + console.log(`\n=== 5. L2->L2 row's derived status ===`); + const l2l2Row = activity.data.find( + (tx) => + L2_NETWORK_IDS.includes(tx.sourceNetwork) && + L2_NETWORK_IDS.includes(tx.destinationNetwork) && + tx.sourceNetwork !== tx.destinationNetwork + ); + assert( + l2l2Row !== undefined, + 'found at least one L2->L2 row in the fetched activity page' + ); + if (l2l2Row) { + console.log( + `L2->L2 row: bridgeHash=${l2l2Row.bridgeHash} sourceNetwork=${l2l2Row.sourceNetwork} ` + + `destinationNetwork=${l2l2Row.destinationNetwork} depositCount=${l2l2Row.depositCount} ` + + `status=${l2l2Row.status} leafIndexForProof=${l2l2Row.leafIndexForProof}` + ); + // This round's known-autoclaimed L2-1->L2-2 deposit (also captured in the + // unit-test lifecycle fixtures): tx 0xac862504..., deposit_count=2. + // Autoclaim landed well before this smoke run, so the derived status + // should be the terminal CLAIMED, not the transient LEAF_INCLUDED window. + if (l2l2Row.depositCount === 2 && l2l2Row.sourceNetwork === 1) { + assert( + l2l2Row.status === 'CLAIMED', + 'the known-autoclaimed L2-1->L2-2 deposit (deposit_count=2) derives CLAIMED' + ); + assert( + l2l2Row.claimTransactionHash !== undefined, + 'CLAIMED L2->L2 row carries a claimTransactionHash' + ); + } + } + + console.log( + `\n=== 6. getClaimInputs — L2->L2 deposit (destination-injected index) ===` + ); + const originBridges = await primaryClient.getBridges({ + networkId: primaryNetworkId, + fromAddress: FROM_ADDRESS, + pageSize: 200, + }); + + const l2l2Sample = originBridges.bridges.find( + (b) => + L2_NETWORK_IDS.includes(b.destination_network) && + b.destination_network !== primaryNetworkId + ); + assert( + l2l2Sample !== undefined, + 'found a real L2->L2 deposit to probe getClaimInputs against' + ); + if (l2l2Sample) { + const { leafIndex, proof, sourceL1InfoTreeIndex } = + await aggregator.getClaimInputs({ + originNetworkId: primaryNetworkId, + destinationNetworkId: l2l2Sample.destination_network, + depositCount: l2l2Sample.deposit_count, + }); + console.log( + `L2->L2 depositCount=${l2l2Sample.deposit_count} destinationNetwork=${l2l2Sample.destination_network} -> ` + + `sourceL1InfoTreeIndex=${sourceL1InfoTreeIndex}, leafIndex=${leafIndex}, ` + + `proof_local_exit_root.length=${proof.proof_local_exit_root.length}, ` + + `proof_rollup_exit_root.length=${proof.proof_rollup_exit_root.length}` + ); + assert(typeof leafIndex === 'number', 'L2->L2 leafIndex is a number'); + assert( + typeof sourceL1InfoTreeIndex === 'number', + 'L2->L2 sourceL1InfoTreeIndex is a number' + ); + assert( + leafIndex >= sourceL1InfoTreeIndex, + 'L2->L2 leafIndex (destination-injected) >= sourceL1InfoTreeIndex' + ); + assert( + proof.proof_local_exit_root.length === 32, + 'L2->L2 proof_local_exit_root has 32 entries' + ); + assert( + proof.proof_rollup_exit_root.length === 32, + 'L2->L2 proof_rollup_exit_root has 32 entries' + ); + assert( + proof.l1_info_tree_leaf.l1_info_tree_index === leafIndex, + 'L2->L2 l1_info_tree_leaf.l1_info_tree_index matches the returned leafIndex' + ); + } + + console.log( + `\n=== 7. getClaimInputs — L2->L1 deposit (destination 0, no injection step) ===` + ); + const l2l1Sample = originBridges.bridges.find( + (b) => b.destination_network === 0 + ); + assert( + l2l1Sample !== undefined, + 'found a real L2->L1 deposit to probe getClaimInputs against' + ); + if (l2l1Sample) { + const { leafIndex, proof, sourceL1InfoTreeIndex } = + await aggregator.getClaimInputs({ + originNetworkId: primaryNetworkId, + destinationNetworkId: 0, + depositCount: l2l1Sample.deposit_count, + }); + console.log( + `L2->L1 depositCount=${l2l1Sample.deposit_count} -> ` + + `sourceL1InfoTreeIndex=${sourceL1InfoTreeIndex}, leafIndex=${leafIndex}, ` + + `proof_local_exit_root.length=${proof.proof_local_exit_root.length}, ` + + `proof_rollup_exit_root.length=${proof.proof_rollup_exit_root.length}` + ); + assert( + leafIndex === sourceL1InfoTreeIndex, + 'L2->L1 (destination 0): leafIndex === sourceL1InfoTreeIndex (no injection step)' + ); + assert( + proof.proof_local_exit_root.length === 32, + 'L2->L1 proof_local_exit_root has 32 entries' + ); + assert( + proof.proof_rollup_exit_root.length === 32, + 'L2->L1 proof_rollup_exit_root has 32 entries' + ); + } + + console.log(`\n=== 8. Token mappings ===`); + const mappings = await primaryClient.getTokenMappings({ + networkId: primaryNetworkId, + }); + console.log(JSON.stringify(mappings)); + assert(Array.isArray(mappings.token_mappings), 'token_mappings is an array'); + assert( + mappings.count === mappings.token_mappings.length, + 'count matches token_mappings.length' + ); + + console.log(`\n=== 9. Proxy-502 partial-failure path ===`); + if (!RUN_PARTIAL_FAILURE_TEST) { + console.log( + 'SKIPPED — set RUN_PARTIAL_FAILURE_TEST=true to run this (mutates the ' + + 'live enclave: stops then restarts aggkit-002-bridge via `kurtosis ' + + 'service stop/start cdk aggkit-002-bridge`).' + ); + } else if (!L2_NETWORK_IDS.includes(2)) { + console.log( + 'SKIPPED — aggkit-002-bridge backs network 2, which ' + + `is not in the configured L2_NETWORK_IDS (${JSON.stringify(L2_NETWORK_IDS)}).` + ); + } else { + // aggkit-002-bridge backs network 2 specifically (the proxy's static + // BridgeURLs map routes 2 -> aggkit-002-bridge); this is NOT "any non-zero + // network" — hardcode it to match the exact service being stopped below. + const downNetworkId = 2; + console.log( + `Stopping aggkit-002-bridge (backing network ${downNetworkId})...` + ); + execSync('kurtosis service stop cdk aggkit-002-bridge', { + stdio: 'inherit', + }); + try { + // The proxy's own port stays open (it's a distinct service) — only the + // backend it routes network 2 to is down, so network 2's calls 502 + // while network 1's fan-out keeps succeeding. + const degraded = await aggregator.getActivity({ + fromAddress: FROM_ADDRESS, + pageSize: 50, + }); + console.log( + `degraded.failedNetworks=${JSON.stringify(degraded.failedNetworks)} degraded.data.length=${degraded.data.length}` + ); + assert( + degraded.failedNetworks.length === 1 && + degraded.failedNetworks[0]?.networkId === downNetworkId, + `getActivity degrades with failedNetworks naming ONLY network ${downNetworkId}` + ); + const otherNetworkId = L2_NETWORK_IDS.find((id) => id !== downNetworkId); + if (otherNetworkId !== undefined) { + const otherNetworkRowsPresent = degraded.data.some( + (tx) => + tx.sourceNetwork === otherNetworkId || + tx.destinationNetwork === otherNetworkId + ); + assert( + otherNetworkRowsPresent, + `network ${otherNetworkId}'s rows are still present while network ${downNetworkId} is down` + ); + } + } finally { + console.log('Restarting aggkit-002-bridge...'); + execSync('kurtosis service start cdk aggkit-002-bridge', { + stdio: 'inherit', + }); + // Give the proxy a moment to observe the restarted backend before any + // subsequent script logic depends on it. + const healedDeadline = Date.now() + 15_000; + let healed = false; + while (Date.now() < healedDeadline && !healed) { + try { + const status = await mustGetClient(downNetworkId).getSyncStatus(); + healed = status.l1_info.is_synced && status.l2_info.is_synced; + } catch { + healed = false; + } + if (!healed) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + assert( + healed, + `aggkit-002-bridge healed (sync-status 200) within 15s of restart` + ); + } + } + + console.log( + `\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`}` + ); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error('aggkit-smoke crashed:', err); + process.exit(1); +}); diff --git a/src/aggkit/__fixtures__/bridges_from_address.json b/src/aggkit/__fixtures__/bridges_from_address.json new file mode 100644 index 0000000..19097b0 --- /dev/null +++ b/src/aggkit/__fixtures__/bridges_from_address.json @@ -0,0 +1,81 @@ +{ + "bridges": [ + { + "block_num": 412, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0xd02dd3030fb78dd985c97d42b831ab89c84c6ab3c4b40d28155306854ae24918", + "global_index": 3, + "block_timestamp": 1783931112, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931110", + "metadata": "0x", + "deposit_count": 3, + "bridge_hash": "0x75033e9a3096c1cdd26b36c38e8d84746bd77f918a5fd9a14b5f8530c95a5676", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 346, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x5b35a7755d6facaa211ecd6ac11684fd2db876f0bd1e0cff8f83e67132f81a76", + "global_index": 2, + "block_timestamp": 1783931046, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931044", + "metadata": "0x", + "deposit_count": 2, + "bridge_hash": "0xb88f06786eb8641cfbdfa19c72c9b807c30fec5febd57a86f9c4076e235e200c", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 280, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0xf5f0da7611d69f194e52f9b15b8bb098b8f2c634c384946fe0578c3aa14cec91", + "global_index": 1, + "block_timestamp": 1783930980, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930978", + "metadata": "0x", + "deposit_count": 1, + "bridge_hash": "0x3975ee60a62a9597560620a0a427f2be95be1412a9569bf1799852f69b54b898", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 214, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x734d05722cb8a0976290fe5dd9333a204f1660980da4927ab337bc474e68498d", + "global_index": 0, + "block_timestamp": 1783930914, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930912", + "metadata": "0x", + "deposit_count": 0, + "bridge_hash": "0xf2a8872fd7729f043e7489b41c47d746f15ef6e357ca0f45726e9872e62032e4", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 4 +} diff --git a/src/aggkit/__fixtures__/bridges_network0.json b/src/aggkit/__fixtures__/bridges_network0.json new file mode 100644 index 0000000..f833bdb --- /dev/null +++ b/src/aggkit/__fixtures__/bridges_network0.json @@ -0,0 +1,119 @@ +{ + "bridges": [ + { + "block_num": 253, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x7f6a8792f39d3e80e4b4404002cd4c67a955b628349c3dc19d88f4217ed57d57", + "global_index": 18446744073709551621, + "block_timestamp": 1783931050, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931047", + "metadata": "0x", + "deposit_count": 5, + "bridge_hash": "0xc74023c27b3672f939979f46a124c11971060ccb20f0a235da3bc0ec35dbb253", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 220, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x84aab3e3fbca250f0d98dadd66805cf8ab875ca9b1859e9e32ff6d2da770ab9c", + "global_index": 18446744073709551620, + "block_timestamp": 1783930984, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930981", + "metadata": "0x", + "deposit_count": 4, + "bridge_hash": "0x0aaf70344d0a776294e609094fcd63829819a5800b7751275e8b055c6e721df3", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 187, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x983b37c99e056bf5413faee71a05b2b79c5add5e9cad588b59b4a621125c5233", + "global_index": 18446744073709551619, + "block_timestamp": 1783930918, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930915", + "metadata": "0x", + "deposit_count": 3, + "bridge_hash": "0xb21a3bf46183e8281871c5bb7901ce04a1fda40d9714fb74d5dbee9f0d34caed", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 154, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0xe0313f6c1b1ee8c2724402f575e119977621b7068bbc0c26931c1017ba952c09", + "global_index": 18446744073709551618, + "block_timestamp": 1783930852, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930849", + "metadata": "0x", + "deposit_count": 2, + "bridge_hash": "0x90e900d35cc9d246271ad052a41572a3253d8a4cfc97918f5f498419cfcb1ee8", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 130, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x26df49cd3af749bd282da52adb224ff5fe4f9155da9988629e3d8e7dc5c6e492", + "global_index": 18446744073709551617, + "block_timestamp": 1783930804, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1000000000000000000", + "metadata": "0x", + "deposit_count": 1, + "bridge_hash": "0xfe39184913e2182ecfd818440a6b58a205e7e1e591109df2232b877f62e958c2", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 51, + "block_pos": 0, + "from_address": "0xE34aaF64b29273B7D567FCFc40544c014EEe9970", + "tx_hash": "0x650e4928cd34ba1cad5d612950664f23d3d23043f2bff7d844c3a3537a378b50", + "global_index": 18446744073709551616, + "block_timestamp": 1783930646, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 7, + "destination_address": "0x0000000000000000000000000000000000000000", + "amount": "0", + "metadata": "0x", + "deposit_count": 0, + "bridge_hash": "0x341d79031c866046fa536c0e63cd5e7e1246cb76f043f1a4b1ea0986b88c422e", + "txn_sender": "0xE34aaF64b29273B7D567FCFc40544c014EEe9970", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 6 +} diff --git a/src/aggkit/__fixtures__/bridges_network1.json b/src/aggkit/__fixtures__/bridges_network1.json new file mode 100644 index 0000000..769714d --- /dev/null +++ b/src/aggkit/__fixtures__/bridges_network1.json @@ -0,0 +1,62 @@ +{ + "bridges": [ + { + "block_num": 346, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x5b35a7755d6facaa211ecd6ac11684fd2db876f0bd1e0cff8f83e67132f81a76", + "global_index": 2, + "block_timestamp": 1783931046, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931044", + "metadata": "0x", + "deposit_count": 2, + "bridge_hash": "0xb88f06786eb8641cfbdfa19c72c9b807c30fec5febd57a86f9c4076e235e200c", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 280, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0xf5f0da7611d69f194e52f9b15b8bb098b8f2c634c384946fe0578c3aa14cec91", + "global_index": 1, + "block_timestamp": 1783930980, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930978", + "metadata": "0x", + "deposit_count": 1, + "bridge_hash": "0x3975ee60a62a9597560620a0a427f2be95be1412a9569bf1799852f69b54b898", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 214, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x734d05722cb8a0976290fe5dd9333a204f1660980da4927ab337bc474e68498d", + "global_index": 0, + "block_timestamp": 1783930914, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930912", + "metadata": "0x", + "deposit_count": 0, + "bridge_hash": "0xf2a8872fd7729f043e7489b41c47d746f15ef6e357ca0f45726e9872e62032e4", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 3 +} diff --git a/src/aggkit/__fixtures__/bridges_page1.json b/src/aggkit/__fixtures__/bridges_page1.json new file mode 100644 index 0000000..18594d3 --- /dev/null +++ b/src/aggkit/__fixtures__/bridges_page1.json @@ -0,0 +1,43 @@ +{ + "bridges": [ + { + "block_num": 285, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x513b90c4d7733d33242f8d22e8e81377bc03519fc9d862271f170fcdb574213d", + "global_index": 18446744073709551622, + "block_timestamp": 1783931114, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931112", + "metadata": "0x", + "deposit_count": 6, + "bridge_hash": "0xf5d32b85302d4477bdbb8bd475b35e17bdb709739abf165c02e52c85f2c6cdde", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 253, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x7f6a8792f39d3e80e4b4404002cd4c67a955b628349c3dc19d88f4217ed57d57", + "global_index": 18446744073709551621, + "block_timestamp": 1783931050, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783931047", + "metadata": "0x", + "deposit_count": 5, + "bridge_hash": "0xc74023c27b3672f939979f46a124c11971060ccb20f0a235da3bc0ec35dbb253", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 7 +} diff --git a/src/aggkit/__fixtures__/bridges_page2.json b/src/aggkit/__fixtures__/bridges_page2.json new file mode 100644 index 0000000..31049ff --- /dev/null +++ b/src/aggkit/__fixtures__/bridges_page2.json @@ -0,0 +1,43 @@ +{ + "bridges": [ + { + "block_num": 220, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x84aab3e3fbca250f0d98dadd66805cf8ab875ca9b1859e9e32ff6d2da770ab9c", + "global_index": 18446744073709551620, + "block_timestamp": 1783930984, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930981", + "metadata": "0x", + "deposit_count": 4, + "bridge_hash": "0x0aaf70344d0a776294e609094fcd63829819a5800b7751275e8b055c6e721df3", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 187, + "block_pos": 0, + "from_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "tx_hash": "0x983b37c99e056bf5413faee71a05b2b79c5add5e9cad588b59b4a621125c5233", + "global_index": 18446744073709551619, + "block_timestamp": 1783930918, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "amount": "1783930915", + "metadata": "0x", + "deposit_count": 3, + "bridge_hash": "0xb21a3bf46183e8281871c5bb7901ce04a1fda40d9714fb74d5dbee9f0d34caed", + "txn_sender": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 7 +} diff --git a/src/aggkit/__fixtures__/claim_proof_error_badindex.json b/src/aggkit/__fixtures__/claim_proof_error_badindex.json new file mode 100644 index 0000000..7f8176d --- /dev/null +++ b/src/aggkit/__fixtures__/claim_proof_error_badindex.json @@ -0,0 +1,3 @@ +{ + "error": "failed to get l1 info tree leaf for index 9999: sql: no rows in result set" +} diff --git a/src/aggkit/__fixtures__/claim_proof_error_missing_param.json b/src/aggkit/__fixtures__/claim_proof_error_missing_param.json new file mode 100644 index 0000000..4d576c1 --- /dev/null +++ b/src/aggkit/__fixtures__/claim_proof_error_missing_param.json @@ -0,0 +1,3 @@ +{ + "error": "deposit_count is mandatory" +} diff --git a/src/aggkit/__fixtures__/claim_proof_valid.json b/src/aggkit/__fixtures__/claim_proof_valid.json new file mode 100644 index 0000000..892b7ad --- /dev/null +++ b/src/aggkit/__fixtures__/claim_proof_valid.json @@ -0,0 +1,81 @@ +{ + "proof_local_exit_root": [ + "0x341d79031c866046fa536c0e63cd5e7e1246cb76f043f1a4b1ea0986b88c422e", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "l1_info_tree_leaf": { + "block_num": 130, + "block_pos": 1, + "l1_info_tree_index": 1, + "previous_block_hash": "0x6cd7ea082e4a70f93869e07d517fc5a0fcf65fe5bb9a9787bb17d14bb3ccba78", + "timestamp": 1783930804, + "mainnet_exit_root": "0x2d60988d34d8dea9686f4ba38ba813457e424cf6cf98836727662bd2b83c6939", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x75ae703ae021a905d944e6c0c134dedad76210cad0e4d48fde186c7c8352b89d", + "hash": "0xf6e551835b25d436991924776c91f44b729bbd31ebb7162dc627f86e6b081c07" + } +} diff --git a/src/aggkit/__fixtures__/claims_network0.json b/src/aggkit/__fixtures__/claims_network0.json new file mode 100644 index 0000000..7599cb3 --- /dev/null +++ b/src/aggkit/__fixtures__/claims_network0.json @@ -0,0 +1,4 @@ +{ + "claims": [], + "count": 0 +} diff --git a/src/aggkit/__fixtures__/claims_network1.json b/src/aggkit/__fixtures__/claims_network1.json new file mode 100644 index 0000000..21cf1df --- /dev/null +++ b/src/aggkit/__fixtures__/claims_network1.json @@ -0,0 +1,90 @@ +{ + "claims": [ + { + "block_num": 403, + "block_timestamp": 1783931103, + "tx_hash": "0xda89db2a2c0a46391d4a5d2b0d64c6bb1613f22c161199e028fb887ebb987093", + "global_index": "18446744073709551621", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783931047", + "from_address": "", + "mainnet_exit_root": "0x7f60b230c09611ff3242947444f59a9c682ecbe14bc63d9c2a59af0ff9e530ed", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x1d78257eb87123d56c4fb081909141cebb76cd21c7b66b0e5c6e17494edc2b4f", + "metadata": "0x", + "is_message": false + }, + { + "block_num": 353, + "block_timestamp": 1783931053, + "tx_hash": "0xc5a236377505344dae7d879981e3946d4cd025c8ba1ad3dffc16d3de9e5ea983", + "global_index": "18446744073709551620", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930981", + "from_address": "", + "mainnet_exit_root": "0x2f88136b96ead8ca9155fa7ad6ff71bd96cd726dc7865355d4f14afdef0f698d", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x8a04d7bd46386cf2f98173f26c54fbad3ccde2a4abd4b99c8e826b7d2b369532", + "metadata": "0x", + "is_message": false + }, + { + "block_num": 278, + "block_timestamp": 1783930978, + "tx_hash": "0x5b6562d24c6c004353ccd8e56534b46e0661d7973ade412dce4d8601e643b5bb", + "global_index": "18446744073709551619", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930915", + "from_address": "", + "mainnet_exit_root": "0x781ee69ab389a2ac8fc77e0e7882b3912af0551df835b543bb6974661b7c3926", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x36bd24b5490ca5451967cda3de4ba4a03323f8fd1a88bd602bf14c7e02929453", + "metadata": "0x", + "is_message": false + }, + { + "block_num": 218, + "block_timestamp": 1783930918, + "tx_hash": "0x0909781e4611d9a835752abff9c855692874901eacefc1c735725c15c8e916e9", + "global_index": "18446744073709551618", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930849", + "from_address": "", + "mainnet_exit_root": "0x6565cc04923435718f47c198e758c233cd171b8fddf1b07a7d905d996e606cc6", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0xd3e92caf6b297f99b2e1de610c2279960073cc41fb36a96d31f895fbbba994da", + "metadata": "0x", + "is_message": false + }, + { + "block_num": 158, + "block_timestamp": 1783930858, + "tx_hash": "0xa016f65a3e8a0f57f745e284786ad032ed53e7fc545aaedd81fb82e8e0ea1c92", + "global_index": "18446744073709551617", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1000000000000000000", + "from_address": "", + "mainnet_exit_root": "0x2d60988d34d8dea9686f4ba38ba813457e424cf6cf98836727662bd2b83c6939", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x75ae703ae021a905d944e6c0c134dedad76210cad0e4d48fde186c7c8352b89d", + "metadata": "0x", + "is_message": false + } + ], + "count": 5 +} diff --git a/src/aggkit/__fixtures__/claims_network1_all_fields.json b/src/aggkit/__fixtures__/claims_network1_all_fields.json new file mode 100644 index 0000000..8cf5fb4 --- /dev/null +++ b/src/aggkit/__fixtures__/claims_network1_all_fields.json @@ -0,0 +1,430 @@ +{ + "claims": [ + { + "block_num": 403, + "block_timestamp": 1783931103, + "tx_hash": "0xda89db2a2c0a46391d4a5d2b0d64c6bb1613f22c161199e028fb887ebb987093", + "global_index": "18446744073709551621", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783931047", + "from_address": "", + "mainnet_exit_root": "0x7f60b230c09611ff3242947444f59a9c682ecbe14bc63d9c2a59af0ff9e530ed", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x1d78257eb87123d56c4fb081909141cebb76cd21c7b66b0e5c6e17494edc2b4f", + "proof_local_exit_root": [ + "0x0aaf70344d0a776294e609094fcd63829819a5800b7751275e8b055c6e721df3", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xe2e2052d13f3b0dbcee21fb8c7df6863a522b79816ba0292277a1ddbaf496107", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "metadata": "0x", + "is_message": false + }, + { + "block_num": 353, + "block_timestamp": 1783931053, + "tx_hash": "0xc5a236377505344dae7d879981e3946d4cd025c8ba1ad3dffc16d3de9e5ea983", + "global_index": "18446744073709551620", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930981", + "from_address": "", + "mainnet_exit_root": "0x2f88136b96ead8ca9155fa7ad6ff71bd96cd726dc7865355d4f14afdef0f698d", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x8a04d7bd46386cf2f98173f26c54fbad3ccde2a4abd4b99c8e826b7d2b369532", + "proof_local_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xe2e2052d13f3b0dbcee21fb8c7df6863a522b79816ba0292277a1ddbaf496107", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "metadata": "0x", + "is_message": false + }, + { + "block_num": 278, + "block_timestamp": 1783930978, + "tx_hash": "0x5b6562d24c6c004353ccd8e56534b46e0661d7973ade412dce4d8601e643b5bb", + "global_index": "18446744073709551619", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930915", + "from_address": "", + "mainnet_exit_root": "0x781ee69ab389a2ac8fc77e0e7882b3912af0551df835b543bb6974661b7c3926", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x36bd24b5490ca5451967cda3de4ba4a03323f8fd1a88bd602bf14c7e02929453", + "proof_local_exit_root": [ + "0x90e900d35cc9d246271ad052a41572a3253d8a4cfc97918f5f498419cfcb1ee8", + "0x4e9885acd5908c89c171db2cce5e047898ac3287ad3e06a097c340a32d9c60d9", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "metadata": "0x", + "is_message": false + }, + { + "block_num": 218, + "block_timestamp": 1783930918, + "tx_hash": "0x0909781e4611d9a835752abff9c855692874901eacefc1c735725c15c8e916e9", + "global_index": "18446744073709551618", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783930849", + "from_address": "", + "mainnet_exit_root": "0x6565cc04923435718f47c198e758c233cd171b8fddf1b07a7d905d996e606cc6", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0xd3e92caf6b297f99b2e1de610c2279960073cc41fb36a96d31f895fbbba994da", + "proof_local_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x4e9885acd5908c89c171db2cce5e047898ac3287ad3e06a097c340a32d9c60d9", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "metadata": "0x", + "is_message": false + }, + { + "block_num": 158, + "block_timestamp": 1783930858, + "tx_hash": "0xa016f65a3e8a0f57f745e284786ad032ed53e7fc545aaedd81fb82e8e0ea1c92", + "global_index": "18446744073709551617", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1000000000000000000", + "from_address": "", + "mainnet_exit_root": "0x2d60988d34d8dea9686f4ba38ba813457e424cf6cf98836727662bd2b83c6939", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x75ae703ae021a905d944e6c0c134dedad76210cad0e4d48fde186c7c8352b89d", + "proof_local_exit_root": [ + "0x341d79031c866046fa536c0e63cd5e7e1246cb76f043f1a4b1ea0986b88c422e", + "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5", + "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30", + "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85", + "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344", + "0x0eb01ebfc9ed27500cd4dfc979272d1f0913cc9f66540d7e8005811109e1cf2d", + "0x887c22bd8750d34016ac3c66b5ff102dacdd73f6b014e710b51e8022af9a1968", + "0xffd70157e48063fc33c97a050f7f640233bf646cc98d9524c6b92bcf3ab56f83", + "0x9867cc5f7f196b93bae1e27e6320742445d290f2263827498b54fec539f756af", + "0xcefad4e508c098b9a7e1d8feb19955fb02ba9675585078710969d3440f5054e0", + "0xf9dc3e7fe016e050eff260334f18a5d4fe391d82092319f5964f2e2eb7c1c3a5", + "0xf8b13a49e282f609c317a833fb8d976d11517c571d1221a265d25af778ecf892", + "0x3490c6ceeb450aecdc82e28293031d10c7d73bf85e57bf041a97360aa2c5d99c", + "0xc1df82d9c4b87413eae2ef048f94b4d3554cea73d92b0f7af96e0271c691e2bb", + "0x5c67add7c6caf302256adedf7ab114da0acfe870d449a3a489f781d659e8becc", + "0xda7bce9f4e8618b6bd2f4132ce798cdc7a60e7e1460a7299e3c6342a579626d2", + "0x2733e50f526ec2fa19a22b31e8ed50f23cd1fdf94c9154ed3a7609a2f1ff981f", + "0xe1d3b5c807b281e4683cc6d6315cf95b9ade8641defcb32372f1c126e398ef7a", + "0x5a2dce0a8a7f68bb74560f8f71837c2c2ebbcbf7fffb42ae1896f13f7c7479a0", + "0xb46a28b6f55540f89444f63de0378e3d121be09e06cc9ded1c20e65876d36aa0", + "0xc65e9645644786b620e2dd2ad648ddfcbf4a7e5b1a3a4ecfe7f64667a3f0b7e2", + "0xf4418588ed35a2458cffeb39b93d26f18d2ab13bdce6aee58e7b99359ec2dfd9", + "0x5a9c16dc00d6ef18b7933a6f8dc65ccb55667138776f7dea101070dc8796e377", + "0x4df84f40ae0c8229d0d6069e5c8f39a7c299677a09d367fc7b05e3bc380ee652", + "0xcdc72595f74c7b1043d0e1ffbab734648c838dfb0527d971b602bc216c9619ef", + "0x0abf5ac974a1ed57f4050aa510dd9c74f508277b39d7973bb2dfccc5eeb0618d", + "0xb8cd74046ff337f0a7bf2c8e03e10f642c1886798d71806ab1e888d9e5ee87d0", + "0x838c5655cb21c6cb83313b5a631175dff4963772cce9108188b34ac87c81c41e", + "0x662ee4dd2dd7b2bc707961b1e646c4047669dcb6584f0d8d770daf5d7e7deb2e", + "0x388ab20e2573d171a88108e79d820e98f26c0b84aa8b2f4aa4968dbb818ea322", + "0x93237c50ba75ee485f4c22adf2f741400bdf8d6a9cc7df7ecae576221665d735", + "0x8448818bb4ae4562849e949e17ac16e0be16688e156b5cf15e098c627c0056a9" + ], + "proof_rollup_exit_root": [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "metadata": "0x", + "is_message": false + } + ], + "count": 5 +} diff --git a/src/aggkit/__fixtures__/claims_network1_global_index_filter.json b/src/aggkit/__fixtures__/claims_network1_global_index_filter.json new file mode 100644 index 0000000..db9858a --- /dev/null +++ b/src/aggkit/__fixtures__/claims_network1_global_index_filter.json @@ -0,0 +1,22 @@ +{ + "claims": [ + { + "block_num": 403, + "block_timestamp": 1783931103, + "tx_hash": "0xda89db2a2c0a46391d4a5d2b0d64c6bb1613f22c161199e028fb887ebb987093", + "global_index": "18446744073709551621", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x3C4d3AAB4356120117E88225e649f0A7ae0401DE", + "destination_network": 1, + "amount": "1783931047", + "from_address": "", + "mainnet_exit_root": "0x7f60b230c09611ff3242947444f59a9c682ecbe14bc63d9c2a59af0ff9e530ed", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000000", + "global_exit_root": "0x1d78257eb87123d56c4fb081909141cebb76cd21c7b66b0e5c6e17494edc2b4f", + "metadata": "0x", + "is_message": false + } + ], + "count": 1 +} diff --git a/src/aggkit/__fixtures__/error_404_unknown_network.json b/src/aggkit/__fixtures__/error_404_unknown_network.json new file mode 100644 index 0000000..38e3fc2 --- /dev/null +++ b/src/aggkit/__fixtures__/error_404_unknown_network.json @@ -0,0 +1,3 @@ +{ + "error": "bridge service url not found for network: network 9" +} diff --git a/src/aggkit/__fixtures__/error_502_stopped_backend.json b/src/aggkit/__fixtures__/error_502_stopped_backend.json new file mode 100644 index 0000000..de8e148 --- /dev/null +++ b/src/aggkit/__fixtures__/error_502_stopped_backend.json @@ -0,0 +1,3 @@ +{ + "error": "bridge service unreachable" +} diff --git a/src/aggkit/__fixtures__/error_missing_network_id.json b/src/aggkit/__fixtures__/error_missing_network_id.json new file mode 100644 index 0000000..44c0d8b --- /dev/null +++ b/src/aggkit/__fixtures__/error_missing_network_id.json @@ -0,0 +1,3 @@ +{ + "error": "network_id is mandatory" +} diff --git a/src/aggkit/__fixtures__/error_unsupported_network_id.json b/src/aggkit/__fixtures__/error_unsupported_network_id.json new file mode 100644 index 0000000..6f2a28b --- /dev/null +++ b/src/aggkit/__fixtures__/error_unsupported_network_id.json @@ -0,0 +1,3 @@ +{ + "error": "unsupported network id: 2" +} diff --git a/src/aggkit/__fixtures__/health.json b/src/aggkit/__fixtures__/health.json new file mode 100644 index 0000000..5fab7bf --- /dev/null +++ b/src/aggkit/__fixtures__/health.json @@ -0,0 +1,5 @@ +{ + "status": "ok", + "time": "2026-07-13T08:25:06.327907418Z", + "version": "421ba23" +} diff --git a/src/aggkit/__fixtures__/l1_info_tree_index_network1_error.json b/src/aggkit/__fixtures__/l1_info_tree_index_network1_error.json new file mode 100644 index 0000000..53e87fa --- /dev/null +++ b/src/aggkit/__fixtures__/l1_info_tree_index_network1_error.json @@ -0,0 +1,3 @@ +{ + "error": "failed to get l1 info tree index for network id 1 and deposit count 0, error: not found" +} diff --git a/src/aggkit/__fixtures__/l1_info_tree_index_notfound_error.json b/src/aggkit/__fixtures__/l1_info_tree_index_notfound_error.json new file mode 100644 index 0000000..3d8717b --- /dev/null +++ b/src/aggkit/__fixtures__/l1_info_tree_index_notfound_error.json @@ -0,0 +1,3 @@ +{ + "error": "failed to get l1 info tree index for network id 0 and deposit count 9999, error: this bridge has not been included on the L1 Info Tree yet" +} diff --git a/src/aggkit/__fixtures__/l1_info_tree_index_valid.json b/src/aggkit/__fixtures__/l1_info_tree_index_valid.json new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/src/aggkit/__fixtures__/l1_info_tree_index_valid.json @@ -0,0 +1 @@ +1 diff --git a/src/aggkit/__fixtures__/l2l1_lifecycle_claims_network0_unclaimed.json b/src/aggkit/__fixtures__/l2l1_lifecycle_claims_network0_unclaimed.json new file mode 100644 index 0000000..18a52ec --- /dev/null +++ b/src/aggkit/__fixtures__/l2l1_lifecycle_claims_network0_unclaimed.json @@ -0,0 +1,22 @@ +{ + "claims": [ + { + "block_num": 914, + "block_timestamp": 1785775534, + "tx_hash": "0x4cc877acae499f898cbc9f47bc36c3ed6b068f347889eb487403561f31d80411", + "global_index": "1", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "destination_network": 0, + "amount": "50000000000000000", + "from_address": "", + "mainnet_exit_root": "0x30fad20b0e01900d3e7760a4f2283f99cd482f2e92c48a2224c16adebc0c9589", + "rollup_exit_root": "0x3d0058dfffe4d8942bb365a2b321231b0a6e17cdebf008a09a3339af22ea1393", + "global_exit_root": "0x7502e6ff31d8c3d627a9ed70ba34c75e315c57f449870fbae2560f654a054405", + "metadata": "0x", + "is_message": false + } + ], + "count": 1 +} diff --git a/src/aggkit/__fixtures__/l2l1_lifecycle_l1_info_tree_index_ready.json b/src/aggkit/__fixtures__/l2l1_lifecycle_l1_info_tree_index_ready.json new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l1_lifecycle_l1_info_tree_index_ready.json @@ -0,0 +1 @@ +7 diff --git a/src/aggkit/__fixtures__/l2l1_lifecycle_origin_bridges_row.json b/src/aggkit/__fixtures__/l2l1_lifecycle_origin_bridges_row.json new file mode 100644 index 0000000..5250f99 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l1_lifecycle_origin_bridges_row.json @@ -0,0 +1,81 @@ +{ + "bridges": [ + { + "block_num": 2098, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0xfe2ddf0b932d1e0cc534067bfadeda9afae8aab6fd6fbb48d7d933393fc3f94d", + "global_index": 3, + "block_timestamp": 1785775954, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "amount": "50000000000000000", + "metadata": "0x", + "deposit_count": 3, + "bridge_hash": "0x96615554c24907c596a359081651887b79eeb5700eb4f54863c3a8b0809d0bf9", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 2080, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0xac862504244c13ce14da7cb484751b8786b91c893d489de4d52125eb610deb7c", + "global_index": 2, + "block_timestamp": 1785775936, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "amount": "100000000000000000", + "metadata": "0x", + "deposit_count": 2, + "bridge_hash": "0xf9242318868e1d109c9c249055f96ffb04913e5ecb05ac7ddf10776df66d45f0", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 726, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0x979da11f1a5201755b87e3d29202db7e337ab65f74aef34127616be9140f4e68", + "global_index": 1, + "block_timestamp": 1785774582, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "amount": "50000000000000000", + "metadata": "0x", + "deposit_count": 1, + "bridge_hash": "0x96615554c24907c596a359081651887b79eeb5700eb4f54863c3a8b0809d0bf9", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 715, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0x00650c3d030c15140737c34fa164ca86eaac31d0447241af5f1b5ef1cd37e961", + "global_index": 0, + "block_timestamp": 1785774571, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "amount": "100000000000000000", + "metadata": "0x", + "deposit_count": 0, + "bridge_hash": "0xf9242318868e1d109c9c249055f96ffb04913e5ecb05ac7ddf10776df66d45f0", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 4 +} diff --git a/src/aggkit/__fixtures__/l2l2_165338016Z_dest_claims.json b/src/aggkit/__fixtures__/l2l2_165338016Z_dest_claims.json new file mode 100644 index 0000000..cf13fc8 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_165338016Z_dest_claims.json @@ -0,0 +1,22 @@ +{ + "claims": [ + { + "block_num": 639, + "block_timestamp": 1785774693, + "tx_hash": "0x0f0702b44b8a389d3a8bc3ddceb29fec40f0d4a379d8797df727010ebaca328d", + "global_index": "0", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "destination_network": 2, + "amount": "100000000000000000", + "from_address": "", + "mainnet_exit_root": "0x30fad20b0e01900d3e7760a4f2283f99cd482f2e92c48a2224c16adebc0c9589", + "rollup_exit_root": "0x5296f9f75640adfcd0323f0ceec8d4b8b28f9f0273ef8ccb116b72fdbc1d1364", + "global_exit_root": "0x604735f870db22166e2bc37d9cbad6783c5f2f907eda1b71e045b5470072ae5a", + "metadata": "0x", + "is_message": false + } + ], + "count": 1 +} diff --git a/src/aggkit/__fixtures__/l2l2_165338016Z_injected_l1_info_leaf_7.json b/src/aggkit/__fixtures__/l2l2_165338016Z_injected_l1_info_leaf_7.json new file mode 100644 index 0000000..6b9c8e8 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_165338016Z_injected_l1_info_leaf_7.json @@ -0,0 +1,3 @@ +{ + "error": "no injected global exit root at or after leaf index 7 yet (not injected)" +} diff --git a/src/aggkit/__fixtures__/l2l2_165338016Z_l1_info_tree_index.json b/src/aggkit/__fixtures__/l2l2_165338016Z_l1_info_tree_index.json new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_165338016Z_l1_info_tree_index.json @@ -0,0 +1 @@ +7 diff --git a/src/aggkit/__fixtures__/l2l2_165346035Z_dest_claims.json b/src/aggkit/__fixtures__/l2l2_165346035Z_dest_claims.json new file mode 100644 index 0000000..441f4cd --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_165346035Z_dest_claims.json @@ -0,0 +1,39 @@ +{ + "claims": [ + { + "block_num": 1969, + "block_timestamp": 1785776023, + "tx_hash": "0x17145dc0ce17e73b6df0e939d2bd82ba4df1e0d9801d677fac51ca5d3461e364", + "global_index": "2", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "destination_network": 2, + "amount": "100000000000000000", + "from_address": "", + "mainnet_exit_root": "0xb95baa2123d348ef6e6bcce08109f2232881723940ae41612bc4a7801f0ecba2", + "rollup_exit_root": "0x1dc6244abad4b765e53a67831f0778a142764a668bb98bb5b63c3d6fa5117b71", + "global_exit_root": "0x0994468b497cb06f95d7cffc448daeae8fb0e8a4b2cfa4919aae0415b095a25e", + "metadata": "0x", + "is_message": false + }, + { + "block_num": 639, + "block_timestamp": 1785774693, + "tx_hash": "0x0f0702b44b8a389d3a8bc3ddceb29fec40f0d4a379d8797df727010ebaca328d", + "global_index": "0", + "origin_address": "0x0000000000000000000000000000000000000000", + "origin_network": 0, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "destination_network": 2, + "amount": "100000000000000000", + "from_address": "", + "mainnet_exit_root": "0x30fad20b0e01900d3e7760a4f2283f99cd482f2e92c48a2224c16adebc0c9589", + "rollup_exit_root": "0x5296f9f75640adfcd0323f0ceec8d4b8b28f9f0273ef8ccb116b72fdbc1d1364", + "global_exit_root": "0x604735f870db22166e2bc37d9cbad6783c5f2f907eda1b71e045b5470072ae5a", + "metadata": "0x", + "is_message": false + } + ], + "count": 2 +} diff --git a/src/aggkit/__fixtures__/l2l2_165346035Z_injected_l1_info_leaf_7.json b/src/aggkit/__fixtures__/l2l2_165346035Z_injected_l1_info_leaf_7.json new file mode 100644 index 0000000..b6442f0 --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_165346035Z_injected_l1_info_leaf_7.json @@ -0,0 +1,11 @@ +{ + "block_num": 1129, + "block_pos": 0, + "l1_info_tree_index": 7, + "previous_block_hash": "0x9b18b16ded46b6ca3a0d964b852cbc43494c75791872009163ac04d65dbe878f", + "timestamp": 1785775964, + "mainnet_exit_root": "0xb95baa2123d348ef6e6bcce08109f2232881723940ae41612bc4a7801f0ecba2", + "rollup_exit_root": "0x1dc6244abad4b765e53a67831f0778a142764a668bb98bb5b63c3d6fa5117b71", + "global_exit_root": "0x0994468b497cb06f95d7cffc448daeae8fb0e8a4b2cfa4919aae0415b095a25e", + "hash": "0x3052a498ad88cae5731e48474221748f6f399ea8ccbfe02011907c78d3a16095" +} diff --git a/src/aggkit/__fixtures__/l2l2_lifecycle_origin_bridges_row.json b/src/aggkit/__fixtures__/l2l2_lifecycle_origin_bridges_row.json new file mode 100644 index 0000000..ffaeb3b --- /dev/null +++ b/src/aggkit/__fixtures__/l2l2_lifecycle_origin_bridges_row.json @@ -0,0 +1,62 @@ +{ + "bridges": [ + { + "block_num": 2080, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0xac862504244c13ce14da7cb484751b8786b91c893d489de4d52125eb610deb7c", + "global_index": 2, + "block_timestamp": 1785775936, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "amount": "100000000000000000", + "metadata": "0x", + "deposit_count": 2, + "bridge_hash": "0xf9242318868e1d109c9c249055f96ffb04913e5ecb05ac7ddf10776df66d45f0", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 726, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0x979da11f1a5201755b87e3d29202db7e337ab65f74aef34127616be9140f4e68", + "global_index": 1, + "block_timestamp": 1785774582, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "amount": "50000000000000000", + "metadata": "0x", + "deposit_count": 1, + "bridge_hash": "0x96615554c24907c596a359081651887b79eeb5700eb4f54863c3a8b0809d0bf9", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + }, + { + "block_num": 715, + "block_pos": 0, + "from_address": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "tx_hash": "0x00650c3d030c15140737c34fa164ca86eaac31d0447241af5f1b5ef1cd37e961", + "global_index": 0, + "block_timestamp": 1785774571, + "leaf_type": 0, + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x6Ac917d111d0577470809D5E34d54A00b97B0462", + "amount": "100000000000000000", + "metadata": "0x", + "deposit_count": 0, + "bridge_hash": "0xf9242318868e1d109c9c249055f96ffb04913e5ecb05ac7ddf10776df66d45f0", + "txn_sender": "0x9BEE1d978DF451350fA93C69c4A1f6fFca12d107", + "to_address": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038" + } + ], + "count": 3 +} diff --git a/src/aggkit/__fixtures__/sync-status.json b/src/aggkit/__fixtures__/sync-status.json new file mode 100644 index 0000000..e7e5f36 --- /dev/null +++ b/src/aggkit/__fixtures__/sync-status.json @@ -0,0 +1,14 @@ +{ + "l1_info": { + "contract_deposit_count": 6, + "synchronized_deposit_count": 6, + "is_synced": true, + "is_active": true + }, + "l2_info": { + "contract_deposit_count": 3, + "synchronized_deposit_count": 3, + "is_synced": true, + "is_active": true + } +} diff --git a/src/aggkit/__fixtures__/sync_status_network2.json b/src/aggkit/__fixtures__/sync_status_network2.json new file mode 100644 index 0000000..d41d0f7 --- /dev/null +++ b/src/aggkit/__fixtures__/sync_status_network2.json @@ -0,0 +1,14 @@ +{ + "l1_info": { + "contract_deposit_count": 5, + "synchronized_deposit_count": 5, + "is_synced": true, + "is_active": true + }, + "l2_info": { + "contract_deposit_count": 1, + "synchronized_deposit_count": 1, + "is_synced": true, + "is_active": true + } +} diff --git a/src/aggkit/__fixtures__/token_mappings_network1.json b/src/aggkit/__fixtures__/token_mappings_network1.json new file mode 100644 index 0000000..c44717a --- /dev/null +++ b/src/aggkit/__fixtures__/token_mappings_network1.json @@ -0,0 +1,4 @@ +{ + "token_mappings": [], + "count": 0 +} diff --git a/src/aggkit/__fixtures__/tracker_error_400.json b/src/aggkit/__fixtures__/tracker_error_400.json new file mode 100644 index 0000000..2b0ec61 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_error_400.json @@ -0,0 +1,4 @@ +{ + "code": 400, + "message": "invalid tx_hash parameter" +} diff --git a/src/aggkit/__fixtures__/tracker_error_giveup.json b/src/aggkit/__fixtures__/tracker_error_giveup.json new file mode 100644 index 0000000..4457e56 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_error_giveup.json @@ -0,0 +1,20 @@ +{ + "tracking_status": "error", + "network_id": 1, + "tx_hash": "0xdeadbeef00000000000000000000000000000000000000000000000000000000", + "bridge_status": null, + "step_index": null, + "all_steps": null, + "error": { + "error_type": 2, + "error_type_string": "exhausted", + "retry_count": 5, + "description": [ + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network", + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network", + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network", + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network", + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network" + ] + } +} diff --git a/src/aggkit/__fixtures__/tracker_l1l2_finished.json b/src/aggkit/__fixtures__/tracker_l1l2_finished.json new file mode 100644 index 0000000..d30be5c --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l1l2_finished.json @@ -0,0 +1,68 @@ +{ + "tracking_status": "finished", + "network_id": 0, + "tx_hash": "0x64b65138996aae61811dac45f10c2baddbf0ab5aae9ef587766b92a23c85791e", + "bridge_status": { + "bridge_type": "L1->L2", + "block_number": 519, + "log_index": 0, + "block_timestamp": 1786113909, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0", + "amount": "1000000000000000000", + "deposit_count": 3 + } + }, + "step_index": 3, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingGERUpdate", + "status": "done", + "start_date": "2026-08-07T14:45:14.940426998Z", + "end_date": "2026-08-07T14:45:14.942687479Z", + "result": { + "l1_info_tree_index": 6, + "ger": "0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090", + "mer": "0xaa7f2b3bcb3d6303a1af1d4b4322d197db525e63e4472ef50c352b316de9598b", + "rer": "0x226608c15eee1d684ad841ee83dc549bc9c3f25ccff4a102d8065aeb90bc6c1c", + "block_number": 519, + "block_timestamp": 1786113909, + "log_index": 2 + } + }, + { + "step_index": 1, + "step_name": "WaitingGERInjection", + "status": "done", + "start_date": "2026-08-07T14:45:14.942687479Z", + "end_date": "2026-08-07T14:45:56.844525693Z", + "result": { + "ger": "0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090" + } + }, + { + "step_index": 2, + "step_name": "WaitingClaim", + "status": "done", + "start_date": "2026-08-07T14:45:56.844525693Z", + "end_date": "2026-08-07T14:46:06.844712083Z", + "result": { + "claim_tx": "0x178eed25e7a70d088367b81879bffb7fa800e3f23789d8a11bd05ae78505e3f3", + "block_number": 909 + } + }, + { + "step_index": 3, + "step_name": "Claimed", + "status": "done", + "start_date": "2026-08-07T14:46:06.844712083Z", + "end_date": "2026-08-07T14:46:06.844712083Z" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_l1l2_running.json b/src/aggkit/__fixtures__/tracker_l1l2_running.json new file mode 100644 index 0000000..d36968f --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l1l2_running.json @@ -0,0 +1,61 @@ +{ + "tracking_status": "running", + "network_id": 0, + "tx_hash": "0x64b65138996aae61811dac45f10c2baddbf0ab5aae9ef587766b92a23c85791e", + "bridge_status": { + "bridge_type": "L1->L2", + "block_number": 519, + "log_index": 0, + "block_timestamp": 1786113909, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 1, + "destination_address": "0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0", + "amount": "1000000000000000000", + "deposit_count": 3 + } + }, + "step_index": 2, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingGERUpdate", + "status": "done", + "start_date": "2026-08-07T14:45:14.940426998Z", + "end_date": "2026-08-07T14:45:14.942687479Z", + "result": { + "l1_info_tree_index": 6, + "ger": "0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090", + "mer": "0xaa7f2b3bcb3d6303a1af1d4b4322d197db525e63e4472ef50c352b316de9598b", + "rer": "0x226608c15eee1d684ad841ee83dc549bc9c3f25ccff4a102d8065aeb90bc6c1c", + "block_number": 519, + "block_timestamp": 1786113909, + "log_index": 2 + } + }, + { + "step_index": 1, + "step_name": "WaitingGERInjection", + "status": "done", + "start_date": "2026-08-07T14:45:14.942687479Z", + "end_date": "2026-08-07T14:45:56.844525693Z", + "result": { + "ger": "0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090" + } + }, + { + "step_index": 2, + "step_name": "WaitingClaim", + "status": "inProgress", + "start_date": "2026-08-07T14:45:56.844525693Z" + }, + { + "step_index": 3, + "step_name": "Claimed", + "status": "pending" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_l2l1_finished.json b/src/aggkit/__fixtures__/tracker_l2l1_finished.json new file mode 100644 index 0000000..aca1c85 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l2l1_finished.json @@ -0,0 +1,95 @@ +{ + "tracking_status": "finished", + "network_id": 1, + "tx_hash": "0xcfbdc931acce665da204150bc025cd76cdbe5566578abaa1ec4ef236fa5c8009", + "bridge_status": { + "bridge_type": "L2->L1", + "block_number": 826, + "log_index": 0, + "block_timestamp": 1786113875, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0", + "amount": "50000000000000000", + "deposit_count": 1 + } + }, + "step_index": 5, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingLERUpdate", + "status": "done", + "start_date": "2026-08-07T14:44:45.774402045Z", + "end_date": "2026-08-07T14:44:45.776405692Z", + "result": { + "network_id": 1, + "ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e", + "block_number": 826 + } + }, + { + "step_index": 1, + "step_name": "PendingInclusion", + "status": "done", + "start_date": "2026-08-07T14:44:45.776405692Z", + "end_date": "2026-08-07T14:44:45.776405692Z", + "result": { + "certificate_id": "0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f", + "new_ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e", + "previous_ler": "0xfd107fe3ba1c4de7139e4ca5d666ec90a7df9698c926f585611eac31ce13192f" + } + }, + { + "step_index": 2, + "step_name": "CertificatePending", + "status": "done", + "start_date": "2026-08-07T14:44:45.776405692Z", + "end_date": "2026-08-07T14:45:06.844644141Z", + "result": { + "certificate_id": "0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f", + "status": 4, + "status_string": "Settled", + "settlement_tx_hash": "0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7" + } + }, + { + "step_index": 3, + "step_name": "WaitL1SettledGER", + "status": "done", + "start_date": "2026-08-07T14:45:06.844644141Z", + "end_date": "2026-08-07T14:45:06.844644141Z", + "result": { + "tx_hash": "0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7", + "block_number": 511, + "ger": "0xe95cc8832a43e15f02052ae8d436589fc0ad89643e4a7a0f1af7242016f173b7", + "l1_info_tree_index": 5, + "has_verify_batches_trusted_aggregator": true, + "has_update_l1_info_tree": true, + "has_update_l1_info_tree_v2": true + } + }, + { + "step_index": 4, + "step_name": "WaitingClaim", + "status": "done", + "start_date": "2026-08-07T14:45:06.844644141Z", + "end_date": "2026-08-07T14:47:26.844119964Z", + "result": { + "claim_tx": "0x51d247094346142f780378bfb82a1e54b152db5d4035ec4e6937c531c47b0145", + "block_number": 583 + } + }, + { + "step_index": 5, + "step_name": "Claimed", + "status": "done", + "start_date": "2026-08-07T14:47:26.844119964Z", + "end_date": "2026-08-07T14:47:26.844119964Z" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_l2l1_running.json b/src/aggkit/__fixtures__/tracker_l2l1_running.json new file mode 100644 index 0000000..c650ea6 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l2l1_running.json @@ -0,0 +1,88 @@ +{ + "tracking_status": "running", + "network_id": 1, + "tx_hash": "0xcfbdc931acce665da204150bc025cd76cdbe5566578abaa1ec4ef236fa5c8009", + "bridge_status": { + "bridge_type": "L2->L1", + "block_number": 826, + "log_index": 0, + "block_timestamp": 1786113875, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0", + "amount": "50000000000000000", + "deposit_count": 1 + } + }, + "step_index": 4, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingLERUpdate", + "status": "done", + "start_date": "2026-08-07T14:44:45.774402045Z", + "end_date": "2026-08-07T14:44:45.776405692Z", + "result": { + "network_id": 1, + "ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e", + "block_number": 826 + } + }, + { + "step_index": 1, + "step_name": "PendingInclusion", + "status": "done", + "start_date": "2026-08-07T14:44:45.776405692Z", + "end_date": "2026-08-07T14:44:45.776405692Z", + "result": { + "certificate_id": "0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f", + "new_ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e", + "previous_ler": "0xfd107fe3ba1c4de7139e4ca5d666ec90a7df9698c926f585611eac31ce13192f" + } + }, + { + "step_index": 2, + "step_name": "CertificatePending", + "status": "done", + "start_date": "2026-08-07T14:44:45.776405692Z", + "end_date": "2026-08-07T14:45:06.844644141Z", + "result": { + "certificate_id": "0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f", + "status": 4, + "status_string": "Settled", + "settlement_tx_hash": "0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7" + } + }, + { + "step_index": 3, + "step_name": "WaitL1SettledGER", + "status": "done", + "start_date": "2026-08-07T14:45:06.844644141Z", + "end_date": "2026-08-07T14:45:06.844644141Z", + "result": { + "tx_hash": "0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7", + "block_number": 511, + "ger": "0xe95cc8832a43e15f02052ae8d436589fc0ad89643e4a7a0f1af7242016f173b7", + "l1_info_tree_index": 5, + "has_verify_batches_trusted_aggregator": true, + "has_update_l1_info_tree": true, + "has_update_l1_info_tree_v2": true + } + }, + { + "step_index": 4, + "step_name": "WaitingClaim", + "status": "inProgress", + "start_date": "2026-08-07T14:45:06.844644141Z" + }, + { + "step_index": 5, + "step_name": "Claimed", + "status": "pending" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_l2l2_finished.json b/src/aggkit/__fixtures__/tracker_l2l2_finished.json new file mode 100644 index 0000000..37be048 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l2l2_finished.json @@ -0,0 +1,105 @@ +{ + "tracking_status": "finished", + "network_id": 1, + "tx_hash": "0x66a20ab10e92748f7ee30f9a487e262a673b790df365bf3067a59c8b71fb2fe8", + "bridge_status": { + "bridge_type": "L2->L2", + "block_number": 1143, + "log_index": 0, + "block_timestamp": 1786114192, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x4e0ff24158eeac22ed9abfe3abbbda6d6a609fe0", + "amount": "20000000000000000", + "deposit_count": 2 + } + }, + "step_index": 6, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingLERUpdate", + "status": "done", + "start_date": "2026-08-07T14:49:59.046296597Z", + "end_date": "2026-08-07T14:49:59.048885846Z", + "result": { + "network_id": 1, + "ler": "0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb", + "block_number": 1143 + } + }, + { + "step_index": 1, + "step_name": "PendingInclusion", + "status": "done", + "start_date": "2026-08-07T14:49:59.048885846Z", + "end_date": "2026-08-07T14:49:59.048885846Z", + "result": { + "certificate_id": "0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2", + "new_ler": "0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb", + "previous_ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e" + } + }, + { + "step_index": 2, + "step_name": "CertificatePending", + "status": "done", + "start_date": "2026-08-07T14:49:59.048885846Z", + "end_date": "2026-08-07T14:50:06.843777138Z", + "result": { + "certificate_id": "0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2", + "status": 4, + "status_string": "Settled", + "settlement_tx_hash": "0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0" + } + }, + { + "step_index": 3, + "step_name": "WaitL1SettledGER", + "status": "done", + "start_date": "2026-08-07T14:50:06.843777138Z", + "end_date": "2026-08-07T14:50:06.843777138Z", + "result": { + "tx_hash": "0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0", + "block_number": 663, + "ger": "0x6989b12606017b91d6defe2184415b5071fb7004e8daee4b3b82efd5e54045ff", + "l1_info_tree_index": 7, + "has_verify_batches_trusted_aggregator": true, + "has_update_l1_info_tree": true, + "has_update_l1_info_tree_v2": true + } + }, + { + "step_index": 4, + "step_name": "WaitingGERInjection", + "status": "done", + "start_date": "2026-08-07T14:50:06.843777138Z", + "end_date": "2026-08-07T14:50:46.843817546Z", + "result": { + "ger": "0x6989b12606017b91d6defe2184415b5071fb7004e8daee4b3b82efd5e54045ff" + } + }, + { + "step_index": 5, + "step_name": "WaitingClaim", + "status": "done", + "start_date": "2026-08-07T14:50:46.843817546Z", + "end_date": "2026-08-07T14:50:46.843817546Z", + "result": { + "claim_tx": "0xea2424b0837070a37feba683b1994357fb92bc3b55116aae528a0f777d7c937c", + "block_number": 990 + } + }, + { + "step_index": 6, + "step_name": "Claimed", + "status": "done", + "start_date": "2026-08-07T14:50:46.843817546Z", + "end_date": "2026-08-07T14:50:46.843817546Z" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_l2l2_running.json b/src/aggkit/__fixtures__/tracker_l2l2_running.json new file mode 100644 index 0000000..17ab1a3 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_l2l2_running.json @@ -0,0 +1,93 @@ +{ + "tracking_status": "running", + "network_id": 1, + "tx_hash": "0x66a20ab10e92748f7ee30f9a487e262a673b790df365bf3067a59c8b71fb2fe8", + "bridge_status": { + "bridge_type": "L2->L2", + "block_number": 1143, + "log_index": 0, + "block_timestamp": 1786114192, + "event": { + "leaf_type": "Asset", + "origin_network": 0, + "origin_address": "0x0000000000000000000000000000000000000000", + "destination_network": 2, + "destination_address": "0x4e0ff24158eeac22ed9abfe3abbbda6d6a609fe0", + "amount": "20000000000000000", + "deposit_count": 2 + } + }, + "step_index": 4, + "all_steps": [ + { + "step_index": 0, + "step_name": "WaitingLERUpdate", + "status": "done", + "start_date": "2026-08-07T14:49:59.046296597Z", + "end_date": "2026-08-07T14:49:59.048885846Z", + "result": { + "network_id": 1, + "ler": "0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb", + "block_number": 1143 + } + }, + { + "step_index": 1, + "step_name": "PendingInclusion", + "status": "done", + "start_date": "2026-08-07T14:49:59.048885846Z", + "end_date": "2026-08-07T14:49:59.048885846Z", + "result": { + "certificate_id": "0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2", + "new_ler": "0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb", + "previous_ler": "0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e" + } + }, + { + "step_index": 2, + "step_name": "CertificatePending", + "status": "done", + "start_date": "2026-08-07T14:49:59.048885846Z", + "end_date": "2026-08-07T14:50:06.843777138Z", + "result": { + "certificate_id": "0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2", + "status": 4, + "status_string": "Settled", + "settlement_tx_hash": "0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0" + } + }, + { + "step_index": 3, + "step_name": "WaitL1SettledGER", + "status": "done", + "start_date": "2026-08-07T14:50:06.843777138Z", + "end_date": "2026-08-07T14:50:06.843777138Z", + "result": { + "tx_hash": "0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0", + "block_number": 663, + "ger": "0x6989b12606017b91d6defe2184415b5071fb7004e8daee4b3b82efd5e54045ff", + "l1_info_tree_index": 7, + "has_verify_batches_trusted_aggregator": true, + "has_update_l1_info_tree": true, + "has_update_l1_info_tree_v2": true + } + }, + { + "step_index": 4, + "step_name": "WaitingGERInjection", + "status": "inProgress", + "start_date": "2026-08-07T14:50:06.843777138Z" + }, + { + "step_index": 5, + "step_name": "WaitingClaim", + "status": "pending" + }, + { + "step_index": 6, + "step_name": "Claimed", + "status": "pending" + } + ], + "error": null +} diff --git a/src/aggkit/__fixtures__/tracker_registered.json b/src/aggkit/__fixtures__/tracker_registered.json new file mode 100644 index 0000000..a091322 --- /dev/null +++ b/src/aggkit/__fixtures__/tracker_registered.json @@ -0,0 +1,16 @@ +{ + "tracking_status": "registered", + "network_id": 1, + "tx_hash": "0xdeadbeef00000000000000000000000000000000000000000000000000000000", + "bridge_status": null, + "step_index": null, + "all_steps": null, + "error": { + "error_type": 0, + "error_type_string": "transient", + "retry_count": 1, + "description": [ + "network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network" + ] + } +} diff --git a/src/aggkit/__tests__/aggregator.test.ts b/src/aggkit/__tests__/aggregator.test.ts new file mode 100644 index 0000000..5abdd5c --- /dev/null +++ b/src/aggkit/__tests__/aggregator.test.ts @@ -0,0 +1,1540 @@ +import { readFileSync } from 'node:fs'; +import { http } from 'viem'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { AggkitBridgeAggregator } from '../aggregator'; +import { AggkitApiError } from '../errors'; +import { chainRegistry } from '../../native/chains/registry'; + +// --------------------------------------------------------------------------- +// viem mock (for the ERC20.getMetadata() on-chain-read branch of +// getTokenMetadata). `readContractImpl` is reassigned per test; the mocked +// `createPublicClient` always delegates to whatever is currently assigned, +// so BaseContract's constructor-time `createPublicClient` call keeps working +// across tests without re-mocking the whole module each time. +// --------------------------------------------------------------------------- +let readContractImpl: (args: { + functionName: string; +}) => Promise = () => + Promise.reject(new Error('readContractImpl not configured')); + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(() => ({ + readContract: (args: { functionName: string }) => readContractImpl(args), + })), + // Spied (not replaced) so `BaseContract`'s `http(config.rpcUrl)` call + // stays observable — used to prove which RPC URL a chain lookup fed + // into the transport, without changing any transport behavior (the + // `createPublicClient` mock above ignores the transport it's given + // anyway). + http: vi.fn((url?: string) => actual.http(url)), + }; +}); + +function loadFixture(name: string): string { + return readFileSync( + new URL(`../__fixtures__/${name}`, import.meta.url), + 'utf-8' + ); +} + +// --------------------------------------------------------------------------- +// URL-routed fetch mock. Each configured network gets its own base URL; +// `installRouter` matches on `startsWith(base)` + required substrings so +// concurrent fan-out calls (A/B/C/D, see aggregator.ts fetchNetworkFanout) to different +// networks/endpoints resolve independently regardless of Promise.all/ +// allSettled ordering. +// --------------------------------------------------------------------------- +interface Rule { + test: (url: string) => boolean; + status: number; + body: string; +} + +function includesAll(url: string, subs: string[]): boolean { + return subs.every((s) => url.includes(s)); +} + +function rule( + base: string, + subs: string[], + status: number, + body: string +): Rule { + return { + test: (url) => url.startsWith(base) && includesAll(url, subs), + status, + body, + }; +} + +function installRouter(rules: Rule[]): void { + global.fetch = vi.fn(async (url: string) => { + const matched = rules.find((r) => r.test(url)); + if (!matched) { + // NOTE: deliberately avoids the substrings "fetch"/"network"/"timeout" + // — `fetchRawText`'s retry heuristic treats those as retryable and + // would otherwise retry (with backoff) an unmatched-route test bug + // for several seconds before failing, instead of failing immediately. + throw new Error(`aggregator.test router: no rule matched URL: ${url}`); + } + return new Response(matched.body, { status: matched.status }); + }) as unknown as typeof fetch; +} + +function bridgesBody(bridges: unknown[], count: number): string { + return JSON.stringify({ bridges, count }); +} + +function claimsBody(claims: unknown[], count: number): string { + return JSON.stringify({ claims, count }); +} + +function errorBody(message: string): string { + return JSON.stringify({ error: message }); +} + +/** Synthetic `/injected-l1-info-leaf` 200 body (shape = AggkitL1InfoTreeLeaf). */ +function injectedLeafBody(l1InfoTreeIndex: number): string { + return JSON.stringify({ + block_num: 1, + block_pos: 0, + l1_info_tree_index: l1InfoTreeIndex, + previous_block_hash: '0xprevhash', + timestamp: 1000, + mainnet_exit_root: '0xmainnetexitroot', + rollup_exit_root: '0xrollupexitroot', + global_exit_root: '0xglobalexitroot', + hash: '0xhash', + }); +} + +/** Minimal synthetic bridge row (small global_index — no BigInt precision concerns). */ +function makeBridge( + overrides: Record +): Record { + return { + block_num: 1, + block_pos: 0, + from_address: '0x3C4d3AAB4356120117E88225e649f0A7ae0401DE', + tx_hash: '0xhash', + global_index: 0, + block_timestamp: 1000, + leaf_type: 0, + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 0, + destination_address: '0x3C4d3AAB4356120117E88225e649f0A7ae0401DE', + amount: '1', + metadata: '0x', + deposit_count: 0, + bridge_hash: '0xbridgehash', + txn_sender: '0x3C4d3AAB4356120117E88225e649f0A7ae0401DE', + to_address: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + ...overrides, + }; +} + +/** Generates the 4 fan-out rules (A/B/C/D) for one network. */ +function networkRules( + base: string, + networkId: number, + responses: { + a?: { status: number; body: string }; + b?: { status: number; body: string }; + c?: { status: number; body: string }; + d?: { status: number; body: string }; + } +): Rule[] { + const empty = bridgesBody([], 0); + const emptyClaims = claimsBody([], 0); + const rules: Rule[] = []; + + rules.push( + rule( + base, + ['/bridges', `network_id=${networkId}`], + responses.a?.status ?? 200, + responses.a?.body ?? empty + ) + ); + rules.push( + rule( + base, + ['/bridges', 'network_id=0', `network_ids=${networkId}`], + responses.b?.status ?? 200, + responses.b?.body ?? empty + ) + ); + rules.push( + rule( + base, + ['/claims', `network_id=${networkId}`], + responses.c?.status ?? 200, + responses.c?.body ?? emptyClaims + ) + ); + rules.push( + rule( + base, + ['/claims', 'network_id=0'], + responses.d?.status ?? 200, + responses.d?.body ?? emptyClaims + ) + ); + + return rules; +} + +const BASE_1 = 'http://127.0.0.1:30001'; +const BASE_2 = 'http://127.0.0.1:30002'; +const ADDRESS = '0x3C4d3AAB4356120117E88225e649f0A7ae0401DE'; + +describe('AggkitBridgeAggregator', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + describe('clientFor / listNetworkIds', () => { + it('exposes configured network ids and clients', () => { + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + expect(aggregator.listNetworkIds().sort()).toEqual([1, 2]); + expect(aggregator.clientFor(1).networkId).toBe(1); + }); + + it('throws when asked for an unconfigured network', () => { + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + expect(() => aggregator.clientFor(99)).toThrow(/no client configured/); + }); + }); + + describe('getActivity — merge across networks', () => { + it('merges bridges from 2 networks, sorted by block_timestamp desc, no duplicates', async () => { + const rowNet1 = makeBridge({ + bridge_hash: '0xnet1row', + block_timestamp: 100, + deposit_count: 1, + global_index: 11, + origin_network: 1, + destination_network: 0, + }); + const rowNet2 = makeBridge({ + bridge_hash: '0xnet2row', + block_timestamp: 200, + deposit_count: 2, + global_index: 22, + origin_network: 2, + destination_network: 0, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: bridgesBody([rowNet1], 1) }, + d: { status: 200, body: claimsBody([{ global_index: '11' }], 1) }, + }), + ...networkRules(BASE_2, 2, { + a: { status: 200, body: bridgesBody([rowNet2], 1) }, + d: { status: 200, body: claimsBody([{ global_index: '22' }], 1) }, + }), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + expect(page.failedNetworks).toEqual([]); + expect(page.data).toHaveLength(2); + // net2's row (timestamp 200) sorts before net1's (timestamp 100), desc. + expect(page.data[0]?.bridgeHash).toBe('0xnet2row'); + expect(page.data[1]?.bridgeHash).toBe('0xnet1row'); + expect(page.data[0]?.status).toBe('CLAIMED'); + expect(page.data[1]?.status).toBe('CLAIMED'); + expect(page.pagination.total).toBe(2); + }); + }); + + describe('getActivity — partial failure', () => { + it('when 1 of 2 networks 503s, returns the healthy network rows AND reports the failure with its network id', async () => { + const healthyRow = makeBridge({ + bridge_hash: '0xhealthy', + block_timestamp: 500, + deposit_count: 7, + global_index: 77, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: bridgesBody([healthyRow], 1) }, + d: { status: 200, body: claimsBody([{ global_index: '77' }], 1) }, + }), + // Network 2 is entirely down: every endpoint 503s. + rule( + BASE_2, + ['/bridges'], + 503, + errorBody('L1/L2 bridge syncer is not available') + ), + rule( + BASE_2, + ['/claims'], + 503, + errorBody('L1/L2 bridge syncer is not available') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + expect(page.data).toHaveLength(1); + expect(page.data[0]?.bridgeHash).toBe('0xhealthy'); + + expect(page.failedNetworks).toHaveLength(1); + expect(page.failedNetworks[0]?.networkId).toBe(2); + expect(page.failedNetworks[0]?.httpStatus).toBe(503); + }); + + it('rejects when ALL configured networks fail', async () => { + installRouter([ + rule(BASE_1, ['/bridges'], 503, errorBody('down')), + rule(BASE_1, ['/claims'], 503, errorBody('down')), + rule(BASE_2, ['/bridges'], 503, errorBody('down')), + rule(BASE_2, ['/claims'], 503, errorBody('down')), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + + await expect( + aggregator.getActivity({ fromAddress: ADDRESS }) + ).rejects.toThrow(/all configured networks failed/); + }); + }); + + describe('getActivity — status derivation: L1 -> L2 (fixture-backed)', () => { + // bridges_network0.json (B call: L1-origin destined to network 1) has 6 + // rows; deposit_count=1 <-> global_index 18446744073709551617. + const TARGET_GLOBAL_INDEX = '18446744073709551617'; + + it('CLAIMED: global_index present in destination claims set (claims_network1.json)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + b: { status: 200, body: loadFixture('bridges_network0.json') }, + c: { status: 200, body: loadFixture('claims_network1.json') }, + }), + // bridges_network0.json's 6th row (deposit_count=0, destined to + // network 7) isn't in claims_network1's set (destined to 1) and so + // falls through to a Tier-2 probe; not the row under test here, but + // still needs a rule or the client's retry loop stalls the test. + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('CLAIMED'); + expect(row?.claimTransactionHash).toBeDefined(); + }); + + it('BRIDGED: not claimed AND l1-info-tree-index probe 500s (l1_info_tree_index_notfound_error.json)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + b: { status: 200, body: loadFixture('bridges_network0.json') }, + }), + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('BRIDGED'); + expect(row?.leafIndexForProof).toBeUndefined(); + }); + + it('READY_TO_CLAIM: not claimed AND l1-info-tree-index probe succeeds (l1_info_tree_index_valid.json)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + b: { status: 200, body: loadFixture('bridges_network0.json') }, + }), + rule( + BASE_1, + ['/l1-info-tree-index'], + 200, + loadFixture('l1_info_tree_index_valid.json') + ), + // destination_network=1 (L2) -> Tier-2b gate applies. + // Injected exactly at the source index -> leafIndexForProof unchanged. + rule( + BASE_1, + ['/injected-l1-info-leaf', 'network_id=1', 'leaf_index=1'], + 200, + injectedLeafBody(1) + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('READY_TO_CLAIM'); + expect(row?.leafIndexForProof).toBe(1); + }); + }); + + describe('getActivity — status derivation: L2 -> L1 (fixture-backed)', () => { + // bridges_network1.json (A call, origin=network 1) has 3 rows; + // deposit_count=0 <-> global_index "0". + const TARGET_GLOBAL_INDEX = '0'; + + it('BRIDGED: claims_network0.json is empty (no autoclaim) AND probe 500s (l1_info_tree_index_network1_error.json)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: loadFixture('bridges_network1.json') }, + d: { status: 200, body: loadFixture('claims_network0.json') }, + }), + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_network1_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('BRIDGED'); + }); + + it('READY_TO_CLAIM: not claimed AND probe succeeds (post-settlement — code-verified against bridge.go, not fixture-captured: all enclave L2->L1 deposits were pre-settlement)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: loadFixture('bridges_network1.json') }, + d: { status: 200, body: loadFixture('claims_network0.json') }, + }), + rule(BASE_1, ['/l1-info-tree-index'], 200, '2'), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('READY_TO_CLAIM'); + expect(row?.leafIndexForProof).toBe(2); + }); + + it('CLAIMED: global_index present in the L1 claims set (synthetic — real claims_network0.json has no L2->L1 autoclaim yet)', async () => { + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: loadFixture('bridges_network1.json') }, + d: { + status: 200, + body: claimsBody( + [ + { + global_index: '0', + tx_hash: '0xclaimtx', + block_timestamp: 999, + block_num: 42, + }, + ], + 1 + ), + }, + }), + // The other 2 rows (global_index "1", "2") are NOT in the synthetic + // claims set above and fall through to a Tier-2 probe; not the row + // under test here, but still needs a rule. + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_network1_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('CLAIMED'); + expect(row?.claimTransactionHash).toBe('0xclaimtx'); + expect(row?.claimBlockNumber).toBe(42); + }); + }); + + describe('getActivity — status derivation: L2 -> L1 native-gas-token withdrawal (regression)', () => { + // Regression: a + // withdrawal of the L2's native gas token (mirrors L1 ETH) always has + // `origin_network: 0`, but is recorded on the L2's OWN local exit tree + // (this row is fetched via call A, `network_id=1`) — NOT network 0's + // tree. The probe must therefore be keyed by the RECORDING network (1), + // not `bridge.origin_network` (0). We install a deliberately-diverging + // pair of `/l1-info-tree-index` rules — network_id=1 (correct) 500s + // "not found", network_id=0 (the old buggy probe target) 200s — so this + // test fails loudly if the probe regresses to using `origin_network`. + it('probes by recording network (1), not origin_network (0): derives BRIDGED, not READY_TO_CLAIM', async () => { + const withdrawalRow = makeBridge({ + bridge_hash: '0xnativewithdrawal', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 0, + deposit_count: 184, + global_index: 184, + block_timestamp: 400, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: bridgesBody([withdrawalRow], 1) }, + }), + // Correct probe target (recordingNetworkId=1): not found -> BRIDGED. + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=184'], + 500, + errorBody( + 'failed to get l1 info tree index for network id 1 and deposit count 184, error: not found' + ) + ), + // Old buggy probe target (bridge.origin_network=0): coincidentally + // succeeds. If the fix regresses, this rule matches instead and the + // test below fails (status would read READY_TO_CLAIM). + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=0', 'deposit_count=184'], + 200, + '184' + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.bridgeHash === '0xnativewithdrawal' + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('BRIDGED'); + expect(row?.leafIndexForProof).toBeUndefined(); + }); + }); + + describe('getActivity — display mapping: sourceNetwork uses the RECORDING network (regression, bug a)', () => { + // Regression (bug a, found during manual validation): toTransaction() + // used to map the displayed + // `sourceNetwork` from `bridge.origin_network`, which is always 0 for a + // withdrawal of the L2's native gas token even though the row is + // recorded on the L2's OWN local exit tree (fetched via call A, + // `network_id=1`). This is the display counterpart of the S6b + // status-derivation fix — `sourceNetwork` must reflect + // `recordingNetworkId` (1), not `bridge.origin_network` (0). + it('displays the recording network (1) as sourceNetwork, not bridge.origin_network (0)', async () => { + const withdrawalRow = makeBridge({ + bridge_hash: '0xnativewithdrawaldisplay', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 0, + deposit_count: 777, + global_index: 777, + block_timestamp: 700, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: bridgesBody([withdrawalRow], 1) }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=777'], + 500, + errorBody( + 'failed to get l1 info tree index for network id 1 and deposit count 777, error: not found' + ) + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.bridgeHash === '0xnativewithdrawaldisplay' + ); + expect(row).toBeDefined(); + // bridge.origin_network is 0 (asset origin, L1 ETH); the recording + // network — and therefore the displayed sourceNetwork — is 1. + expect(row?.sourceNetwork).toBe(1); + expect(row?.destinationNetwork).toBe(0); + }); + }); + + describe('getActivity — status derivation: L2 -> L2 (cross-instance join)', () => { + it('CLAIMED via cross-instance join: destination network Y (6) claims-set contains the origin (5) bridge global_index', async () => { + const row = makeBridge({ + bridge_hash: '0xl2l2claimed', + origin_network: 5, + destination_network: 6, + deposit_count: 10, + global_index: 10, + block_timestamp: 300, + }); + + installRouter([ + ...networkRules(BASE_1, 5, { + a: { status: 200, body: bridgesBody([row], 1) }, + }), + ...networkRules(BASE_2, 6, { + c: { status: 200, body: claimsBody([{ global_index: '10' }], 1) }, + }), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 5: BASE_1, 6: BASE_2 }, + }); + + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + const tx = page.data.find((t) => t.bridgeHash === '0xl2l2claimed'); + + expect(tx).toBeDefined(); + expect(tx?.status).toBe('CLAIMED'); + expect(tx?.sourceNetwork).toBe(5); + expect(tx?.destinationNetwork).toBe(6); + }); + + it('READY_TO_CLAIM via origin-instance probe when destination network Y (6) has no matching claim', async () => { + const row = makeBridge({ + bridge_hash: '0xl2l2ready', + origin_network: 5, + destination_network: 6, + deposit_count: 11, + global_index: 11, + block_timestamp: 301, + }); + + installRouter([ + ...networkRules(BASE_1, 5, { + a: { status: 200, body: bridgesBody([row], 1) }, + }), + ...networkRules(BASE_2, 6, {}), + rule(BASE_1, ['/l1-info-tree-index'], 200, '1'), + // destination_network=6 (L2) -> Tier-2b gate queries network 6's own + // instance (BASE_2). Injected exactly at the source index. + rule( + BASE_2, + ['/injected-l1-info-leaf', 'network_id=6', 'leaf_index=1'], + 200, + injectedLeafBody(1) + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 5: BASE_1, 6: BASE_2 }, + }); + + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + const tx = page.data.find((t) => t.bridgeHash === '0xl2l2ready'); + + expect(tx).toBeDefined(); + expect(tx?.status).toBe('READY_TO_CLAIM'); + expect(tx?.leafIndexForProof).toBe(1); + }); + }); + + describe('getActivity — L2 -> L2 injected-leaf gate (live-captured l2l2_* lifecycle fixtures)', () => { + // Origin tx 0xac862504..., L2-1 deposit_count=2, global_index=2, + // destination_network=2. l2l2_lifecycle_origin_bridges_row.json's Tier-2a + // probe (l1-info-tree-index) is 200 with body 7 in both snapshots below; + // only the destination's injected-leaf response differs (the premature + // 404 vs. the post-injection 200): the premature window must derive + // LEAF_INCLUDED, never an actionable READY_TO_CLAIM. + const TARGET_GLOBAL_INDEX = '2'; + + it('LEAF_INCLUDED (not READY_TO_CLAIM) during the premature window: source settled (N=7) but destination GER not yet injected (16:53:38.016Z snapshot)', async () => { + installRouter([ + // confirmClaimed's targeted global_index query (bug-b backstop) MUST + // be distinguished from the generic dest_claims rule below (which + // carries an unrelated global_index="0" claim) — otherwise the + // targeted query would wrongly "confirm" this row as claimed. + rule( + BASE_2, + ['/claims', 'network_id=2', 'global_index=2'], + 200, + claimsBody([], 0) + ), + ...networkRules(BASE_1, 1, { + a: { + status: 200, + body: loadFixture('l2l2_lifecycle_origin_bridges_row.json'), + }, + }), + ...networkRules(BASE_2, 2, { + c: { + status: 200, + body: loadFixture('l2l2_165338016Z_dest_claims.json'), + }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'deposit_count=2'], + 200, + loadFixture('l2l2_165338016Z_l1_info_tree_index.json') + ), + // Other rows in the same fixture (deposit_count 0/1) aren't the + // target of this test but still need a rule or the router throws. + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + rule( + BASE_2, + ['/injected-l1-info-leaf', 'network_id=2', 'leaf_index=7'], + 404, + loadFixture('l2l2_165338016Z_injected_l1_info_leaf_7.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('LEAF_INCLUDED'); + expect(row?.leafIndexForProof).toBeUndefined(); + expect(page.failedNetworks).toEqual([]); + }); + + it('READY_TO_CLAIM with leafIndexForProof = 7 once the destination GER is injected (16:53:46.035Z snapshot, counterfactual pre-autoclaim)', async () => { + installRouter([ + rule( + BASE_2, + ['/claims', 'network_id=2', 'global_index=2'], + 200, + claimsBody([], 0) + ), + ...networkRules(BASE_1, 1, { + a: { + status: 200, + body: loadFixture('l2l2_lifecycle_origin_bridges_row.json'), + }, + }), + ...networkRules(BASE_2, 2, { + // Still not-yet-claimed (count=1) — isolates the injected-leaf + // transition from the autoclaim that landed moments later live. + c: { + status: 200, + body: loadFixture('l2l2_165338016Z_dest_claims.json'), + }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'deposit_count=2'], + 200, + loadFixture('l2l2_165338016Z_l1_info_tree_index.json') + ), + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + rule( + BASE_2, + ['/injected-l1-info-leaf', 'network_id=2', 'leaf_index=7'], + 200, + loadFixture('l2l2_165346035Z_injected_l1_info_leaf_7.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find( + (tx) => tx.globalIndex === TARGET_GLOBAL_INDEX + ); + expect(row).toBeDefined(); + expect(row?.status).toBe('READY_TO_CLAIM'); + expect(row?.leafIndexForProof).toBe(7); + }); + }); + + describe('getActivity — L2 -> L1 (destination 0) skips Tier-2b entirely (live-captured l2l1_* lifecycle fixtures)', () => { + it('derives READY_TO_CLAIM with no /injected-l1-info-leaf request', async () => { + installRouter([ + // confirmClaimed's targeted global_index=3 query MUST be + // distinguished from the generic claims_network0 rule below (which + // carries an unrelated already-claimed global_index="1" row). + rule( + BASE_1, + ['/claims', 'network_id=0', 'global_index=3'], + 200, + claimsBody([], 0) + ), + ...networkRules(BASE_1, 1, { + a: { + status: 200, + body: loadFixture('l2l1_lifecycle_origin_bridges_row.json'), + }, + d: { + status: 200, + body: loadFixture('l2l1_lifecycle_claims_network0_unclaimed.json'), + }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'deposit_count=3'], + 200, + loadFixture('l2l1_lifecycle_l1_info_tree_index_ready.json') + ), + // Other rows in the fixture (deposit_count 0/1/2) aren't the target + // of this test but still need a rule or the router throws. + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find((tx) => tx.globalIndex === '3'); + expect(row).toBeDefined(); + expect(row?.status).toBe('READY_TO_CLAIM'); + expect(row?.leafIndexForProof).toBe(7); + + const calls = (global.fetch as Mock).mock.calls as [string][]; + const injectedLeafCalls = calls.filter(([url]) => + url.includes('/injected-l1-info-leaf') + ); + expect(injectedLeafCalls).toHaveLength(0); + }); + }); + + describe('getActivity — proxy 502 on a per-row destination probe', () => { + it('resolves (does not reject) the whole getActivity call; the row degrades to LEAF_INCLUDED and failedNetworks names only the failing destination network', async () => { + installRouter([ + rule( + BASE_2, + ['/claims', 'network_id=2', 'global_index=2'], + 200, + claimsBody([], 0) + ), + ...networkRules(BASE_1, 1, { + a: { + status: 200, + body: loadFixture('l2l2_lifecycle_origin_bridges_row.json'), + }, + }), + ...networkRules(BASE_2, 2, { + c: { + status: 200, + body: loadFixture('l2l2_165338016Z_dest_claims.json'), + }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'deposit_count=2'], + 200, + loadFixture('l2l2_165338016Z_l1_info_tree_index.json') + ), + rule( + BASE_1, + ['/l1-info-tree-index'], + 500, + loadFixture('l1_info_tree_index_notfound_error.json') + ), + // Network 2's backend is "stopped" — the destination-injected-leaf + // probe 502s (__fixtures__/error_502_stopped_backend.json). + rule( + BASE_2, + ['/injected-l1-info-leaf'], + 502, + loadFixture('error_502_stopped_backend.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + + const page = await aggregator.getActivity({ fromAddress: ADDRESS }); + + const row = page.data.find((tx) => tx.globalIndex === '2'); + expect(row).toBeDefined(); + expect(row?.status).toBe('LEAF_INCLUDED'); + + expect(page.failedNetworks).toHaveLength(1); + expect(page.failedNetworks[0]?.networkId).toBe(2); + expect(page.failedNetworks[0]?.httpStatus).toBe(502); + }); + }); + + describe('getReadyToClaimCount', () => { + it('counts only the unclaimed rows whose l1-info-tree-index probe succeeds, bounded to the unclaimed set', async () => { + const readyRow = makeBridge({ + bridge_hash: '0xready', + deposit_count: 1, + global_index: 1, + origin_network: 1, + }); + const bridgedRow = makeBridge({ + bridge_hash: '0xbridged', + deposit_count: 2, + global_index: 2, + origin_network: 1, + }); + const claimedRow = makeBridge({ + bridge_hash: '0xclaimed', + deposit_count: 3, + global_index: 3, + origin_network: 1, + // Matched against network 1's OWN claims set ("claimsHere", the `c` + // call below), so its destination must be network 1. + destination_network: 1, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { + status: 200, + body: bridgesBody([readyRow, bridgedRow, claimedRow], 3), + }, + c: { status: 200, body: claimsBody([{ global_index: '3' }], 1) }, + }), + rule(BASE_1, ['/l1-info-tree-index', 'deposit_count=1'], 200, '1'), + rule( + BASE_1, + ['/l1-info-tree-index', 'deposit_count=2'], + 500, + errorBody('this bridge has not been included on the L1 Info Tree yet') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const count = await aggregator.getReadyToClaimCount({ + fromAddress: ADDRESS, + }); + + // Only 0xready resolves READY_TO_CLAIM; 0xbridged probes not-ready; + // 0xclaimed is excluded from the unclaimed set entirely (never probed). + expect(count).toBe(1); + }); + + it('regression: native-gas-token withdrawal (origin_network=0, recorded on network 1) is NOT counted ready when only the origin_network=0 probe would coincidentally succeed', async () => { + const withdrawalRow = makeBridge({ + bridge_hash: '0xnativewithdrawalcount', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 0, + deposit_count: 184, + global_index: 184, + }); + + installRouter([ + ...networkRules(BASE_1, 1, { + a: { status: 200, body: bridgesBody([withdrawalRow], 1) }, + }), + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=184'], + 500, + errorBody( + 'failed to get l1 info tree index for network id 1 and deposit count 184, error: not found' + ) + ), + // Coincidental collision if the probe wrongly used origin_network=0. + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=0', 'deposit_count=184'], + 200, + '184' + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const count = await aggregator.getReadyToClaimCount({ + fromAddress: ADDRESS, + }); + + expect(count).toBe(0); + }); + }); + + describe('claims-pagination correctness (regression, bug b)', () => { + // Regression (bug b, found during manual validation): + // fetchNetworkFanout()'s /claims call only ever + // reads page 1 (page_size 200), with no address filter. Once a + // network's total claim count exceeds one page, an already-claimed + // deposit whose claim landed beyond page 1 is invisible to the Tier-1 + // claims-set join and gets mis-derived as READY_TO_CLAIM. The fix adds a + // targeted `/claims?global_index=` confirmation for candidates that + // pass the Tier-2 leaf-included probe (i.e. that would otherwise become + // READY_TO_CLAIM), bounded to that small per-page candidate set. + // + // Here, page 1 of /claims?network_id=1 comes back EMPTY with a large + // `count` (348) — simulating the real deposit's claim sitting on some + // later page — while the targeted global_index query for this exact + // deposit DOES find it. Both getActivity (status derivation) and + // getReadyToClaimCount (badge count) must apply the same confirmation. + it('a deposit whose claim is not on page 1 of /claims is derived CLAIMED (not READY_TO_CLAIM) and is NOT counted by getReadyToClaimCount', async () => { + const paginatedClaimRow = makeBridge({ + bridge_hash: '0xpaginatedclaim', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 1, + deposit_count: 500, + global_index: 500, + block_timestamp: 500, + }); + + const routerRules = [ + // Targeted per-candidate confirmation (must be listed before the + // generic page-1 /claims rule below so it's matched first — it + // additionally requires `global_index=500` in the URL). + rule( + BASE_1, + ['/claims', 'network_id=1', 'global_index=500'], + 200, + claimsBody( + [ + { + tx_hash: '0xconfirmedclaimtxhash', + global_index: '500', + block_timestamp: 999, + block_num: 42, + }, + ], + 1 + ) + ), + ...networkRules(BASE_1, 1, { + b: { status: 200, body: bridgesBody([paginatedClaimRow], 1) }, + // Page 1 of /claims?network_id=1 does NOT include this deposit's + // claim, but `count` (348) indicates more pages exist — exactly + // the shape that caused the original false READY_TO_CLAIM. + c: { status: 200, body: claimsBody([], 348) }, + }), + // Leaf-included probe succeeds (recordingNetworkId=0, an L1-origin + // deposit fetched via call B) — this is what makes the row a + // READY_TO_CLAIM *candidate*, triggering the targeted confirmation. + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=0', 'deposit_count=500'], + 200, + '500' + ), + ]; + + installRouter(routerRules); + const activityAggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const page = await activityAggregator.getActivity({ + fromAddress: ADDRESS, + }); + const row = page.data.find((tx) => tx.bridgeHash === '0xpaginatedclaim'); + expect(row).toBeDefined(); + expect(row?.status).toBe('CLAIMED'); + expect(row?.claimTransactionHash).toBe('0xconfirmedclaimtxhash'); + + // getReadyToClaimCount must apply the same confirmation, over a fresh + // router install (getReadyToClaimCount re-fetches independently). + installRouter(routerRules); + const countAggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const count = await countAggregator.getReadyToClaimCount({ + fromAddress: ADDRESS, + }); + expect(count).toBe(0); + }); + }); + + describe('getClaimInputs', () => { + it('L1 -> L2: uses the DESTINATION network client with network_id=0 (origin) for both calls, and builds /claim-proof on the destination-injected index', async () => { + installRouter([ + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=0', 'deposit_count=1'], + 200, + loadFixture('l1_info_tree_index_valid.json') + ), + // destinationNetworkId=1 (L2) -> the gate applies; injected exactly + // at the source index in this test (no S2-style skip). + rule( + BASE_1, + ['/injected-l1-info-leaf', 'network_id=1', 'leaf_index=1'], + 200, + injectedLeafBody(1) + ), + rule( + BASE_1, + ['/claim-proof', 'network_id=0', 'leaf_index=1', 'deposit_count=1'], + 200, + loadFixture('claim_proof_valid.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const result = await aggregator.getClaimInputs({ + originNetworkId: 0, + destinationNetworkId: 1, + depositCount: 1, + }); + + expect(result.leafIndex).toBe(1); + expect(result.sourceL1InfoTreeIndex).toBe(1); + expect(result.proof.l1_info_tree_leaf.l1_info_tree_index).toBe(1); + }); + + it('getClaimInputs uses the INJECTED index M > N for /claim-proof (synthetic M=3/N=2, mirroring a live enclave case)', async () => { + installRouter([ + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=0', 'deposit_count=2'], + 200, + '2' + ), + // Injection skipped ahead: first injected leaf at-or-after N=2 is M=3 + // (mirrors the live case where a claim built on the deposit's own index 2 + // reverted GlobalExitRootInvalid and only succeeded on injected index 3). + rule( + BASE_1, + ['/injected-l1-info-leaf', 'network_id=1', 'leaf_index=2'], + 200, + injectedLeafBody(3) + ), + rule( + BASE_1, + ['/claim-proof', 'network_id=0', 'leaf_index=3', 'deposit_count=2'], + 200, + loadFixture('claim_proof_valid.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + const result = await aggregator.getClaimInputs({ + originNetworkId: 0, + destinationNetworkId: 1, + depositCount: 2, + }); + + expect(result.sourceL1InfoTreeIndex).toBe(2); + expect(result.leafIndex).toBe(3); + }); + + it('getClaimInputs throws 404 /injected-l1-info-leaf while the destination has not injected the GER yet', async () => { + installRouter([ + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=2'], + 200, + loadFixture('l2l2_165338016Z_l1_info_tree_index.json') + ), + rule( + BASE_2, + ['/injected-l1-info-leaf', 'network_id=2', 'leaf_index=7'], + 404, + loadFixture('l2l2_165338016Z_injected_l1_info_leaf_7.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1, 2: BASE_2 }, + }); + + let caught: unknown; + try { + await aggregator.getClaimInputs({ + originNetworkId: 1, + destinationNetworkId: 2, + depositCount: 2, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(AggkitApiError); + expect((caught as AggkitApiError).httpStatus).toBe(404); + expect((caught as AggkitApiError).endpoint).toBe( + '/injected-l1-info-leaf' + ); + expect((caught as AggkitApiError).message).toMatch(/not injected/); + }); + + it('L2 -> L1: uses the ORIGIN network client with network_id=', async () => { + installRouter([ + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=0'], + 500, + loadFixture('l1_info_tree_index_network1_error.json') + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + + await expect( + aggregator.getClaimInputs({ + originNetworkId: 1, + destinationNetworkId: 0, + depositCount: 0, + }) + ).rejects.toBeInstanceOf(AggkitApiError); + }); + + it('L2 -> L1 (destination 0) skips Tier-2b entirely: no /injected-l1-info-leaf request is made', async () => { + installRouter([ + rule( + BASE_1, + ['/l1-info-tree-index', 'network_id=1', 'deposit_count=3'], + 200, + loadFixture('l2l1_lifecycle_l1_info_tree_index_ready.json') + ), + rule( + BASE_1, + ['/claim-proof', 'network_id=1', 'leaf_index=7', 'deposit_count=3'], + 200, + loadFixture('claim_proof_valid.json') + ), + // If the gate wrongly fires for destination 0, this unmatched + // /injected-l1-info-leaf request throws immediately (router has no + // rule for it) instead of silently succeeding. + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + + const result = await aggregator.getClaimInputs({ + originNetworkId: 1, + destinationNetworkId: 0, + depositCount: 3, + }); + + expect(result.sourceL1InfoTreeIndex).toBe(7); + expect(result.leafIndex).toBe(7); + const calls = (global.fetch as Mock).mock.calls as [string][]; + const injectedLeafCalls = calls.filter(([url]) => + url.includes('/injected-l1-info-leaf') + ); + expect(injectedLeafCalls).toHaveLength(0); + }); + }); + + describe('getTokenMetadata', () => { + const NATIVE_NETWORK_ID = 700; + + beforeEach(() => { + chainRegistry.registerChain({ + chainId: 700700, + networkId: NATIVE_NETWORK_ID, + name: 'Test Native Chain', + rpcUrl: 'http://unused-rpc.test', + nativeCurrency: { name: 'Test Ether', symbol: 'tETH', decimals: 18 }, + }); + }); + + it('native branch: returns the chain nativeCurrency for the zero address', async () => { + const aggregator = new AggkitBridgeAggregator({ + networks: { [NATIVE_NETWORK_ID]: BASE_1 }, + }); + + const metadata = await aggregator.getTokenMetadata( + '0x0000000000000000000000000000000000000000', + NATIVE_NETWORK_ID + ); + + expect(metadata).toEqual({ + name: 'Test Ether', + symbol: 'tETH', + decimals: 18, + tokenAddress: '0x0000000000000000000000000000000000000000', + network: NATIVE_NETWORK_ID, + }); + // No aggkit HTTP call should happen for the native short-circuit. + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('ERC20 branch: composes token-mappings + on-chain reads into the UI TokenMetadata shape', async () => { + const ERC20_NETWORK_ID = 701; + const TOKEN_ADDRESS = '0x1111111111111111111111111111111111111111'; + + chainRegistry.registerChain({ + chainId: 701701, + networkId: ERC20_NETWORK_ID, + name: 'Test ERC20 Chain', + rpcUrl: 'http://unused-rpc.test', + nativeCurrency: { name: 'Test Ether', symbol: 'tETH', decimals: 18 }, + }); + + readContractImpl = (args) => { + switch (args.functionName) { + case 'name': + return Promise.resolve('Test Token'); + case 'symbol': + return Promise.resolve('TST'); + case 'decimals': + return Promise.resolve(6); + case 'totalSupply': + return Promise.resolve(1_000_000n); + default: + return Promise.reject( + new Error(`unexpected call: ${args.functionName}`) + ); + } + }; + + installRouter([ + rule( + BASE_1, + ['/token-mappings', `network_id=${ERC20_NETWORK_ID}`], + 200, + JSON.stringify({ + token_mappings: [ + { + block_num: 1, + block_pos: 0, + block_timestamp: 1, + tx_hash: '0xmap', + origin_network: 0, + origin_token_address: TOKEN_ADDRESS, + wrapped_token_address: '0xwrapped', + metadata: '0x', + is_not_mintable: false, + token_type: 1, + }, + ], + count: 1, + }) + ), + ]); + + const aggregator = new AggkitBridgeAggregator({ + networks: { [ERC20_NETWORK_ID]: BASE_1 }, + }); + + const metadata = await aggregator.getTokenMetadata( + TOKEN_ADDRESS, + ERC20_NETWORK_ID + ); + + expect(metadata.name).toBe('Test Token'); + expect(metadata.symbol).toBe('TST'); + expect(metadata.decimals).toBe(6); + expect(metadata.totalSupply).toBe('1000000'); + expect(metadata.tokenAddress).toBe(TOKEN_ADDRESS); + expect(metadata.originTokenAddress).toBe(TOKEN_ADDRESS); + expect(metadata.originTokenNetwork).toBe(0); + expect(metadata.wrappedTokenAddressV1).toBe('0xwrapped'); + }); + + it('L1-origin (networkId 0) lookup routes through a configured L2 instance instead of throwing (regression, bug c)', async () => { + // Regression (bug c, found during manual validation): getTokenMetadata() + // used to call `this.clientFor(networkId)` directly, which throws "no + // client configured for network 0" when only L2 instances are + // configured — L1 has no dedicated aggkit instance. This mirrors + // the L1 routing `getClaimInputs` already implements: any configured + // L2 instance's embedded L1 syncer serves `network_id=0` queries. + const TOKEN_ADDRESS = '0x2222222222222222222222222222222222222222'; + + readContractImpl = (args) => { + switch (args.functionName) { + case 'name': + return Promise.resolve('L1 Test Token'); + case 'symbol': + return Promise.resolve('L1T'); + case 'decimals': + return Promise.resolve(18); + case 'totalSupply': + return Promise.resolve(500n); + default: + return Promise.reject( + new Error(`unexpected call: ${args.functionName}`) + ); + } + }; + + installRouter([ + rule( + BASE_1, + [ + '/token-mappings', + 'network_id=0', + `origin_token_address=${TOKEN_ADDRESS}`, + ], + 200, + JSON.stringify({ token_mappings: [], count: 0 }) + ), + ]); + + // Only network 1 (an L2) is configured — no "network 0" client exists; + // L1 data is always served via an L2 instance's embedded L1 syncer. + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + + const metadata = await aggregator.getTokenMetadata(TOKEN_ADDRESS, 0); + + expect(metadata.name).toBe('L1 Test Token'); + expect(metadata.symbol).toBe('L1T'); + expect(metadata.decimals).toBe(18); + expect(metadata.tokenAddress).toBe(TOKEN_ADDRESS); + expect(metadata.network).toBe(0); + }); + + it('networkId-0 collision: a registered devnet L1 wins over the default Ethereum mainnet chain — no request is ever constructed toward eth.llamarpc.com', async () => { + // Reproduces a previously-dormant consumer scenario: + // a consumer registers a devnet L1 at networkId 0 (as + // `agglayer-dev-ui`'s aggLayerSdk.tsx does), then requests metadata + // for an ERC20 token bridged from that L1 that isn't in the UI's + // static token list — exercising the on-chain `ERC20.getMetadata()` + // read path, which is where the collision used to leak the default + // mainnet chain's rpcUrl (`https://eth.llamarpc.com`) into a real + // outbound RPC request instead of the devnet's. + const DEVNET_CHAIN_ID = 900000; + const DEVNET_RPC = 'http://devnet-l1.internal.test:8545'; + const TOKEN_ADDRESS = '0x3333333333333333333333333333333333333333'; + + chainRegistry.registerChain({ + chainId: DEVNET_CHAIN_ID, + networkId: 0, + name: 'Devnet L1', + rpcUrl: DEVNET_RPC, + nativeCurrency: { name: 'Devnet Ether', symbol: 'dETH', decimals: 18 }, + }); + + readContractImpl = (args) => { + switch (args.functionName) { + case 'name': + return Promise.resolve('Devnet Token'); + case 'symbol': + return Promise.resolve('DVT'); + case 'decimals': + return Promise.resolve(18); + case 'totalSupply': + return Promise.resolve(0n); + default: + return Promise.reject( + new Error(`unexpected call: ${args.functionName}`) + ); + } + }; + + installRouter([ + rule( + BASE_1, + [ + '/token-mappings', + 'network_id=0', + `origin_token_address=${TOKEN_ADDRESS}`, + ], + 200, + JSON.stringify({ token_mappings: [], count: 0 }) + ), + ]); + + // Ignore any transport calls made by earlier tests in this file — + // only this test's own RPC-URL usage matters below. + (http as unknown as Mock).mockClear(); + + // Only network 1 (an L2) is configured — L1 (networkId 0) has no + // dedicated aggkit instance, so this routes through network 1's + // configured client, same as the L1-origin test above. + const aggregator = new AggkitBridgeAggregator({ + networks: { 1: BASE_1 }, + }); + + const metadata = await aggregator.getTokenMetadata(TOKEN_ADDRESS, 0); + + expect(metadata.name).toBe('Devnet Token'); + expect(metadata.symbol).toBe('DVT'); + expect(metadata.network).toBe(0); + + const rpcUrlsRequested = (http as unknown as Mock).mock.calls.map( + (call) => call[0] + ); + expect(rpcUrlsRequested).toContain(DEVNET_RPC); + expect(rpcUrlsRequested).not.toContain('https://eth.llamarpc.com'); + }); + }); +}); diff --git a/src/aggkit/__tests__/client.test.ts b/src/aggkit/__tests__/client.test.ts new file mode 100644 index 0000000..454ba0b --- /dev/null +++ b/src/aggkit/__tests__/client.test.ts @@ -0,0 +1,537 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { AggkitBridgeClient } from '../client'; +import { AggkitApiError } from '../errors'; + +function loadFixture(name: string): string { + return readFileSync( + new URL(`../__fixtures__/${name}`, import.meta.url), + 'utf-8' + ); +} + +function mockResponse(text: string, status: number): Response { + return new Response(text, { status }); +} + +function mockFetchOnce(text: string, status: number): void { + (global.fetch as Mock).mockResolvedValueOnce(mockResponse(text, status)); +} + +function lastFetchUrl(): string { + const mock = global.fetch as Mock; + const call = mock.mock.calls[mock.mock.calls.length - 1] as [string, unknown]; + return call[0]; +} + +const BASE_URL = 'http://127.0.0.1:33460'; + +describe('AggkitBridgeClient', () => { + let client: AggkitBridgeClient; + + beforeEach(() => { + global.fetch = vi.fn(); + client = new AggkitBridgeClient({ baseUrl: BASE_URL, networkId: 1 }); + }); + + describe('URL construction', () => { + it('appends /bridge/v1 to the base URL', async () => { + mockFetchOnce(loadFixture('sync-status.json'), 200); + await client.getSyncStatus(); + // getSyncStatus sends network_id explicitly (aggkit-proxy 400s an + // unqualified /sync-status request). + expect(lastFetchUrl()).toBe( + `${BASE_URL}/bridge/v1/sync-status?network_id=1` + ); + }); + + it('trims a trailing slash from baseUrl before appending /bridge/v1', async () => { + const trailingClient = new AggkitBridgeClient({ + baseUrl: `${BASE_URL}/`, + networkId: 1, + }); + mockFetchOnce(loadFixture('sync-status.json'), 200); + await trailingClient.getSyncStatus(); + expect(lastFetchUrl()).toBe( + `${BASE_URL}/bridge/v1/sync-status?network_id=1` + ); + }); + + it('trims multiple trailing slashes from baseUrl before appending /bridge/v1', async () => { + const trailingClient = new AggkitBridgeClient({ + baseUrl: `${BASE_URL}///`, + networkId: 1, + }); + mockFetchOnce(loadFixture('sync-status.json'), 200); + await trailingClient.getSyncStatus(); + expect(lastFetchUrl()).toBe( + `${BASE_URL}/bridge/v1/sync-status?network_id=1` + ); + }); + + it('trims a very long run of trailing slashes without hanging', async () => { + // Regression test for the trailing-slash trim (formerly `/\/+$/`, + // flagged by CodeQL as a potential ReDoS source on library-supplied + // `baseUrl`). Asserts both correctness and that a long adversarial + // input resolves promptly. + const manySlashes = `${BASE_URL}${'/'.repeat(50_000)}`; + const trailingClient = new AggkitBridgeClient({ + baseUrl: manySlashes, + networkId: 1, + }); + mockFetchOnce(loadFixture('sync-status.json'), 200); + await trailingClient.getSyncStatus(); + expect(lastFetchUrl()).toBe( + `${BASE_URL}/bridge/v1/sync-status?network_id=1` + ); + }); + + it('requests the root URL (not /bridge/v1) for getHealth', async () => { + mockFetchOnce(loadFixture('health.json'), 200); + await client.getHealth(); + expect(lastFetchUrl()).toBe(`${BASE_URL}/`); + }); + }); + + describe('getBridges', () => { + it('parses bridges_network0.json and preserves global_index as a BigInt-safe string', async () => { + mockFetchOnce(loadFixture('bridges_network0.json'), 200); + + const result = await client.getBridges({ networkId: 0 }); + + expect(result.count).toBe(6); + expect(result.bridges).toHaveLength(6); + + const first = result.bridges[0]; + expect(first).toBeDefined(); + // Bare JSON number 18446744073709551621 exceeds Number.MAX_SAFE_INTEGER; + // must be carried as a string, not corrupted into a JS number. + expect(typeof first?.global_index).toBe('string'); + expect(first?.global_index).toBe('18446744073709551621'); + expect(first?.deposit_count).toBe(5); + expect(first?.origin_network).toBe(0); + expect(first?.destination_network).toBe(1); + expect(first?.amount).toBe('1783931047'); + expect(first?.bridge_hash).toBe( + '0xc74023c27b3672f939979f46a124c11971060ccb20f0a235da3bc0ec35dbb253' + ); + + // BigInt(...) must not throw / lose precision on the returned string. + expect(BigInt(first?.global_index ?? '0')).toBe(18446744073709551621n); + }); + + it('parses bridges_network1.json (L2-origin, small global_index values)', async () => { + mockFetchOnce(loadFixture('bridges_network1.json'), 200); + + const result = await client.getBridges({ networkId: 1 }); + + expect(result.count).toBe(3); + expect(result.bridges).toHaveLength(3); + expect(result.bridges[0]?.global_index).toBe('2'); + expect(result.bridges[0]?.deposit_count).toBe(2); + expect(result.bridges[0]?.destination_network).toBe(0); + }); + + it('serializes fromAddress into the from_address query param', async () => { + mockFetchOnce(loadFixture('bridges_from_address.json'), 200); + + const result = await client.getBridges({ + networkId: 1, + fromAddress: '0x3C4d3AAB4356120117E88225e649f0A7ae0401DE', + }); + + expect(result.count).toBe(4); + const url = lastFetchUrl(); + expect(url).toContain('network_id=1'); + expect(url).toContain( + 'from_address=0x3C4d3AAB4356120117E88225e649f0A7ae0401DE' + ); + }); + + it('supports pagination: page_number/page_size are sent and count is the TOTAL across pages', async () => { + mockFetchOnce(loadFixture('bridges_page1.json'), 200); + const page1 = await client.getBridges({ + networkId: 0, + pageNumber: 1, + pageSize: 2, + }); + expect(lastFetchUrl()).toContain('page_number=1'); + expect(lastFetchUrl()).toContain('page_size=2'); + expect(page1.bridges).toHaveLength(2); + expect(page1.count).toBe(7); + expect(page1.bridges[0]?.deposit_count).toBe(6); + expect(page1.bridges[1]?.deposit_count).toBe(5); + + mockFetchOnce(loadFixture('bridges_page2.json'), 200); + const page2 = await client.getBridges({ + networkId: 0, + pageNumber: 2, + pageSize: 2, + }); + expect(lastFetchUrl()).toContain('page_number=2'); + expect(lastFetchUrl()).toContain('page_size=2'); + expect(page2.bridges).toHaveLength(2); + // count is the TOTAL matching count, identical across pages, NOT the + // current page's array length. + expect(page2.count).toBe(7); + expect(page2.bridges[0]?.deposit_count).toBe(4); + expect(page2.bridges[1]?.deposit_count).toBe(3); + }); + + it('serializes networkIds (destination filter) as a CSV network_ids param', async () => { + mockFetchOnce(loadFixture('bridges_network0.json'), 200); + await client.getBridges({ networkId: 0, networkIds: [1, 2, 3] }); + expect(lastFetchUrl()).toContain('network_ids=1%2C2%2C3'); + }); + + it('throws RangeError when pageSize exceeds 200', async () => { + await expect( + client.getBridges({ networkId: 0, pageSize: 201 }) + ).rejects.toThrow(RangeError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('throws RangeError when networkIds has more than 5 entries', async () => { + await expect( + client.getBridges({ networkId: 0, networkIds: [1, 2, 3, 4, 5, 6] }) + ).rejects.toThrow(RangeError); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('throws AggkitApiError(400) for error_unsupported_network_id.json', async () => { + mockFetchOnce(loadFixture('error_unsupported_network_id.json'), 400); + + let caught: unknown; + try { + await client.getBridges({ networkId: 2 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(AggkitApiError); + expect((caught as AggkitApiError).httpStatus).toBe(400); + expect((caught as AggkitApiError).message).toBe( + 'unsupported network id: 2' + ); + expect((caught as AggkitApiError).endpoint).toBe('/bridges'); + }); + + it('throws AggkitApiError(400) for error_missing_network_id.json', async () => { + mockFetchOnce(loadFixture('error_missing_network_id.json'), 400); + + let caught: unknown; + try { + // networkId is required by the TS signature, but the server enforces + // it too; simulate the 400 body aggkit returns when it's absent. + await client.getBridges({ networkId: 0 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(AggkitApiError); + expect((caught as AggkitApiError).httpStatus).toBe(400); + expect((caught as AggkitApiError).message).toBe( + 'network_id is mandatory' + ); + }); + }); + + describe('getClaims', () => { + it('parses claims_network1.json — global_index arrives as a JSON string already and is preserved', async () => { + mockFetchOnce(loadFixture('claims_network1.json'), 200); + + const result = await client.getClaims({ networkId: 1 }); + + expect(result.count).toBe(5); + expect(result.claims).toHaveLength(5); + const first = result.claims[0]; + expect(typeof first?.global_index).toBe('string'); + expect(first?.global_index).toBe('18446744073709551621'); + expect(first?.from_address).toBe(''); + expect(first?.is_message).toBe(false); + expect(first?.proof_local_exit_root).toBeUndefined(); + }); + + it('parses claims_network0.json (empty — no L2->L1 autoclaim in this dataset)', async () => { + mockFetchOnce(loadFixture('claims_network0.json'), 200); + + const result = await client.getClaims({ networkId: 0 }); + + expect(result.count).toBe(0); + expect(result.claims).toEqual([]); + }); + + it('parses claims_network1_all_fields.json — includeAllFields adds proof arrays', async () => { + mockFetchOnce(loadFixture('claims_network1_all_fields.json'), 200); + + const result = await client.getClaims({ + networkId: 1, + includeAllFields: true, + }); + + expect(lastFetchUrl()).toContain('include_all_fields=true'); + expect(result.claims).toHaveLength(5); + const first = result.claims[0]; + expect(first?.proof_local_exit_root).toHaveLength(32); + expect(first?.proof_rollup_exit_root).toHaveLength(32); + expect(first?.proof_local_exit_root?.[0]).toBe( + '0x0aaf70344d0a776294e609094fcd63829819a5800b7751275e8b055c6e721df3' + ); + }); + + it('parses claims_network1_global_index_filter.json and sends global_index as a query param', async () => { + mockFetchOnce( + loadFixture('claims_network1_global_index_filter.json'), + 200 + ); + + const result = await client.getClaims({ + networkId: 1, + globalIndex: '18446744073709551621', + }); + + expect(lastFetchUrl()).toContain('global_index=18446744073709551621'); + expect(result.count).toBe(1); + expect(result.claims[0]?.global_index).toBe('18446744073709551621'); + }); + }); + + describe('getL1InfoTreeIndex', () => { + it('returns the parsed bare number on success (l1_info_tree_index_valid.json)', async () => { + mockFetchOnce(loadFixture('l1_info_tree_index_valid.json'), 200); + + const result = await client.getL1InfoTreeIndex({ + networkId: 0, + depositCount: 1, + }); + + expect(result).toBe(1); + }); + + it('returns null when the deposit has not been included yet (l1_info_tree_index_notfound_error.json, 500)', async () => { + mockFetchOnce(loadFixture('l1_info_tree_index_notfound_error.json'), 500); + + const result = await client.getL1InfoTreeIndex({ + networkId: 0, + depositCount: 9999, + }); + + expect(result).toBeNull(); + }); + + it('returns null for the L2-origin "not found" 500 variant (l1_info_tree_index_network1_error.json)', async () => { + mockFetchOnce(loadFixture('l1_info_tree_index_network1_error.json'), 500); + + const result = await client.getL1InfoTreeIndex({ + networkId: 1, + depositCount: 0, + }); + + expect(result).toBeNull(); + }); + + it('throws AggkitApiError for a 400 on this endpoint (does not swallow non-500 errors)', async () => { + mockFetchOnce(loadFixture('error_missing_network_id.json'), 400); + + await expect( + client.getL1InfoTreeIndex({ networkId: 0, depositCount: 1 }) + ).rejects.toMatchObject({ + httpStatus: 400, + endpoint: '/l1-info-tree-index', + }); + }); + }); + + describe('getClaimProof', () => { + it('parses claim_proof_valid.json', async () => { + mockFetchOnce(loadFixture('claim_proof_valid.json'), 200); + + const result = await client.getClaimProof({ + networkId: 0, + leafIndex: 1, + depositCount: 1, + }); + + expect(result.proof_local_exit_root).toHaveLength(32); + expect(result.proof_rollup_exit_root).toHaveLength(32); + expect(result.proof_local_exit_root[0]).toBe( + '0x341d79031c866046fa536c0e63cd5e7e1246cb76f043f1a4b1ea0986b88c422e' + ); + expect(result.l1_info_tree_leaf.l1_info_tree_index).toBe(1); + expect(result.l1_info_tree_leaf.mainnet_exit_root).toBe( + '0x2d60988d34d8dea9686f4ba38ba813457e424cf6cf98836727662bd2b83c6939' + ); + + const url = lastFetchUrl(); + expect(url).toContain('network_id=0'); + expect(url).toContain('leaf_index=1'); + expect(url).toContain('deposit_count=1'); + }); + + it('throws AggkitApiError(500) for claim_proof_error_badindex.json', async () => { + mockFetchOnce(loadFixture('claim_proof_error_badindex.json'), 500); + + await expect( + client.getClaimProof({ networkId: 0, leafIndex: 9999, depositCount: 1 }) + ).rejects.toMatchObject({ + httpStatus: 500, + endpoint: '/claim-proof', + message: + 'failed to get l1 info tree leaf for index 9999: sql: no rows in result set', + }); + }); + + it('throws AggkitApiError(400) for claim_proof_error_missing_param.json', async () => { + mockFetchOnce(loadFixture('claim_proof_error_missing_param.json'), 400); + + let caught: unknown; + try { + // depositCount required by the TS signature; simulating the server's + // 400 response to a request missing it. + await client.getClaimProof({ + networkId: 0, + leafIndex: 1, + depositCount: 1, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(AggkitApiError); + expect((caught as AggkitApiError).httpStatus).toBe(400); + expect((caught as AggkitApiError).message).toBe( + 'deposit_count is mandatory' + ); + }); + }); + + describe('getInjectedL1InfoLeaf', () => { + it('parses the 200 body (l2l2_165346035Z_injected_l1_info_leaf_7.json) — post-injection', async () => { + mockFetchOnce( + loadFixture('l2l2_165346035Z_injected_l1_info_leaf_7.json'), + 200 + ); + + const result = await client.getInjectedL1InfoLeaf({ + networkId: 2, + leafIndex: 7, + }); + + expect(result).not.toBeNull(); + expect(result?.l1_info_tree_index).toBe(7); + expect(result?.mainnet_exit_root).toBe( + '0xb95baa2123d348ef6e6bcce08109f2232881723940ae41612bc4a7801f0ecba2' + ); + const url = lastFetchUrl(); + expect(url).toContain('network_id=2'); + expect(url).toContain('leaf_index=7'); + }); + + it('returns null for the documented 404 "not injected" branch (l2l2_165338016Z_injected_l1_info_leaf_7.json) — premature window', async () => { + mockFetchOnce( + loadFixture('l2l2_165338016Z_injected_l1_info_leaf_7.json'), + 404 + ); + + const result = await client.getInjectedL1InfoLeaf({ + networkId: 2, + leafIndex: 7, + }); + + expect(result).toBeNull(); + }); + + it('throws (does NOT return null) for the proxy "bridge service url not found" 404 (error_404_unknown_network.json) — the two 404 shapes collide, so "not injected" must be matched by message', async () => { + mockFetchOnce(loadFixture('error_404_unknown_network.json'), 404); + + await expect( + client.getInjectedL1InfoLeaf({ networkId: 9, leafIndex: 7 }) + ).rejects.toMatchObject({ + httpStatus: 404, + endpoint: '/injected-l1-info-leaf', + message: 'bridge service url not found for network: network 9', + }); + }); + + it('throws AggkitApiError for a 502 (proxy backend unreachable, error_502_stopped_backend.json)', async () => { + mockFetchOnce(loadFixture('error_502_stopped_backend.json'), 502); + + await expect( + client.getInjectedL1InfoLeaf({ networkId: 2, leafIndex: 7 }) + ).rejects.toMatchObject({ + httpStatus: 502, + endpoint: '/injected-l1-info-leaf', + }); + }); + }); + + describe('getTokenMappings', () => { + it('parses token_mappings_network1.json (empty — native-only enclave)', async () => { + mockFetchOnce(loadFixture('token_mappings_network1.json'), 200); + + const result = await client.getTokenMappings({ networkId: 1 }); + + expect(result.count).toBe(0); + expect(result.token_mappings).toEqual([]); + }); + + it('throws RangeError when pageSize exceeds 200', async () => { + await expect( + client.getTokenMappings({ networkId: 1, pageSize: 500 }) + ).rejects.toThrow(RangeError); + }); + }); + + describe('getSyncStatus', () => { + it('parses sync-status.json', async () => { + mockFetchOnce(loadFixture('sync-status.json'), 200); + + const result = await client.getSyncStatus(); + + expect(result.l1_info).toEqual({ + contract_deposit_count: 6, + synchronized_deposit_count: 6, + is_synced: true, + is_active: true, + }); + expect(result.l2_info).toEqual({ + contract_deposit_count: 3, + synchronized_deposit_count: 3, + is_synced: true, + is_active: true, + }); + }); + + it('sends network_id — required for aggkit-proxy, which 400s "missing mandatory query parameter: network_id" on an unqualified request', async () => { + const network2Client = new AggkitBridgeClient({ + baseUrl: BASE_URL, + networkId: 2, + }); + mockFetchOnce(loadFixture('sync_status_network2.json'), 200); + + const result = await network2Client.getSyncStatus(); + + expect(lastFetchUrl()).toContain('network_id=2'); + expect(result.l1_info.is_synced).toBe(true); + expect(result.l2_info.is_synced).toBe(true); + }); + }); + + describe('getHealth', () => { + it('parses health.json', async () => { + mockFetchOnce(loadFixture('health.json'), 200); + + const result = await client.getHealth(); + + expect(result.status).toBe('ok'); + expect(result.version).toBe('421ba23'); + }); + }); + + describe('networkId property', () => { + it('exposes the configured networkId', () => { + const n = new AggkitBridgeClient({ baseUrl: BASE_URL, networkId: 42 }); + expect(n.networkId).toBe(42); + }); + }); +}); diff --git a/src/aggkit/__tests__/tracker.test.ts b/src/aggkit/__tests__/tracker.test.ts new file mode 100644 index 0000000..4f164ad --- /dev/null +++ b/src/aggkit/__tests__/tracker.test.ts @@ -0,0 +1,340 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { AggkitBridgeClient } from '../client'; +import { AggkitBridgeAggregator } from '../aggregator'; +import { AggkitApiError } from '../errors'; +import type { AggkitTrackingData } from '../types'; + +// --------------------------------------------------------------------------- +// Bridge-tracker (`tracker/v1`) unit tests, run against LIVE fixtures +// captured live from a real v0.11.0-rc4 devnet enclave on 2026-08-07 +// (copied in as `tracker_*.json`). At capture time, these shapes disagreed +// with rc4's docs/bridgetracker/API.md (agglayer/aggkit#1781); rc4's +// fixtures were treated as the source of truth over the docs. That gap is +// now closed upstream: v0.11.0-rc5 (PR agglayer/aggkit#1784) corrected +// API.md to match the wire format exactly, and the shapes below were +// re-verified byte-identical on a real rc5 enclave on 2026-08-10. No +// fixture recapture was needed; these rc4 captures remain valid. See +// `types.ts`'s tracker-section module doc for the full wire-format writeup. +// --------------------------------------------------------------------------- + +function loadFixture(name: string): string { + return readFileSync( + new URL(`../__fixtures__/${name}`, import.meta.url), + 'utf-8' + ); +} + +function mockResponse(text: string, status: number): Response { + return new Response(text, { status }); +} + +function mockFetchOnce(text: string, status: number): void { + (global.fetch as Mock).mockResolvedValueOnce(mockResponse(text, status)); +} + +function lastFetchUrl(): string { + const mock = global.fetch as Mock; + const call = mock.mock.calls[mock.mock.calls.length - 1] as [string, unknown]; + return call[0]; +} + +const BASE_URL = 'http://127.0.0.1:33460'; + +describe('AggkitBridgeClient.getBridgeTracking', () => { + let client: AggkitBridgeClient; + + beforeEach(() => { + global.fetch = vi.fn(); + client = new AggkitBridgeClient({ baseUrl: BASE_URL, networkId: 1 }); + }); + + describe('URL construction', () => { + it("builds /tracker/v1/network/{id}/tx/{hash} using the client's own networkId by default", async () => { + mockFetchOnce(loadFixture('tracker_l2l1_running.json'), 200); + const hash = + '0xcfbdc931acce665da204150bc025cd76cdbe5566578abaa1ec4ef236fa5c8009'; + await client.getBridgeTracking(hash); + expect(lastFetchUrl()).toBe( + `${BASE_URL}/tracker/v1/network/1/tx/${hash}` + ); + }); + + it("uses the explicit networkId argument over the client's own networkId when passed", async () => { + mockFetchOnce(loadFixture('tracker_l1l2_finished.json'), 200); + const hash = + '0x64b65138996aae61811dac45f10c2baddbf0ab5aae9ef587766b92a23c85791e'; + // client is bound to networkId 1, but the caller explicitly routes L1 (0). + await client.getBridgeTracking(hash, 0); + expect(lastFetchUrl()).toBe( + `${BASE_URL}/tracker/v1/network/0/tx/${hash}` + ); + }); + }); + + describe('registered-only tracking data', () => { + it('parses tracker_registered.json: bridge_status/step_index/all_steps null, error populated', async () => { + mockFetchOnce(loadFixture('tracker_registered.json'), 200); + const data = await client.getBridgeTracking( + '0xdeadbeef00000000000000000000000000000000000000000000000000000000' + ); + + expect(data.tracking_status).toBe('registered'); + expect(data.bridge_status).toBeNull(); + expect(data.step_index).toBeNull(); + expect(data.all_steps).toBeNull(); + expect(data.error).not.toBeNull(); + expect(data.error?.error_type).toBe(0); + expect(data.error?.error_type_string).toBe('transient'); + expect(data.error?.retry_count).toBe(1); + }); + }); + + describe('giving-up error tracking data', () => { + it('parses tracker_error_giveup.json: tracking_status "error", error_type 2/"exhausted"', async () => { + mockFetchOnce(loadFixture('tracker_error_giveup.json'), 200); + const data = await client.getBridgeTracking( + '0xdeadbeef00000000000000000000000000000000000000000000000000000000' + ); + + expect(data.tracking_status).toBe('error'); + expect(data.bridge_status).toBeNull(); + expect(data.all_steps).toBeNull(); + expect(data.error).not.toBeNull(); + // numeric + string companion DOES follow the documented convention for error_type. + expect(data.error?.error_type).toBe(2); + expect(data.error?.error_type_string).toBe('exhausted'); + expect(data.error?.retry_count).toBe(5); + expect(data.error?.description).toHaveLength(5); + }); + }); + + describe('L1->L2 typology (4 steps)', () => { + it('parses a mid-flight run (tracker_l1l2_running.json)', async () => { + mockFetchOnce(loadFixture('tracker_l1l2_running.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.tracking_status).toBe('running'); + expect(data.bridge_status?.bridge_type).toBe('L1->L2'); + expect(data.bridge_status?.event.leaf_type).toBe('Asset'); + expect(data.step_index).toBe(2); + expect(data.all_steps).toHaveLength(4); + // WaitingGERInjection just completed; WaitingClaim now inProgress; the + // still-`pending` steps carry no start_date/end_date/result keys at all. + expect(data.all_steps?.[1]?.step_name).toBe('WaitingGERInjection'); + expect(data.all_steps?.[1]?.status).toBe('done'); + expect(data.all_steps?.[2]?.step_name).toBe('WaitingClaim'); + expect(data.all_steps?.[2]?.status).toBe('inProgress'); + expect(data.all_steps?.[3]?.status).toBe('pending'); + expect(data.all_steps?.[3]?.start_date).toBeUndefined(); + expect(data.all_steps?.[3]?.result).toBeUndefined(); + }); + + it('parses the terminal `finished` route (tracker_l1l2_finished.json) with correct step count/order/results', async () => { + mockFetchOnce(loadFixture('tracker_l1l2_finished.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.tracking_status).toBe('finished'); + expect(data.step_index).toBe(3); + expect(data.all_steps).toHaveLength(4); + expect(data.all_steps?.map((s) => s.step_name)).toEqual([ + 'WaitingGERUpdate', + 'WaitingGERInjection', + 'WaitingClaim', + 'Claimed', + ]); + + const gerUpdate = data.all_steps?.[0]; + expect(gerUpdate?.result).toMatchObject({ + l1_info_tree_index: 6, + ger: '0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090', + }); + + const gerInjection = data.all_steps?.[1]; + expect(gerInjection?.result).toEqual({ + ger: '0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090', + }); + + const waitingClaim = data.all_steps?.[2]; + expect(waitingClaim?.result).toMatchObject({ + claim_tx: + '0x178eed25e7a70d088367b81879bffb7fa800e3f23789d8a11bd05ae78505e3f3', + }); + + const claimed = data.all_steps?.[3]; + expect(claimed?.status).toBe('done'); + expect(claimed?.result).toBeUndefined(); + }); + }); + + describe('L2->L1 typology (6 steps)', () => { + it('parses a mid-flight run (tracker_l2l1_running.json), including certificate + WaitL1SettledGER results', async () => { + mockFetchOnce(loadFixture('tracker_l2l1_running.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.bridge_status?.bridge_type).toBe('L2->L1'); + expect(data.all_steps).toHaveLength(6); + // No WaitingGERInjection step for an L1-destination route. + expect(data.all_steps?.map((s) => s.step_name)).toEqual([ + 'WaitingLERUpdate', + 'PendingInclusion', + 'CertificatePending', + 'WaitL1SettledGER', + 'WaitingClaim', + 'Claimed', + ]); + + const certStep = data.all_steps?.[2]; + expect(certStep?.status).toBe('done'); + // certificate status DOES follow the documented numeric + string convention. + expect(certStep?.result).toMatchObject({ + status: 4, + status_string: 'Settled', + settlement_tx_hash: + '0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7', + }); + + const settledGer = data.all_steps?.[3]; + expect(settledGer?.result).toMatchObject({ + l1_info_tree_index: 5, + has_verify_batches_trusted_aggregator: true, + }); + + expect(data.all_steps?.[4]?.step_name).toBe('WaitingClaim'); + expect(data.all_steps?.[4]?.status).toBe('inProgress'); + }); + + it('parses the terminal `finished` route (tracker_l2l1_finished.json) with the manually-submitted claim', async () => { + mockFetchOnce(loadFixture('tracker_l2l1_finished.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.tracking_status).toBe('finished'); + expect(data.step_index).toBe(5); + expect(data.all_steps).toHaveLength(6); + + const waitingClaim = data.all_steps?.[4]; + expect(waitingClaim?.result).toMatchObject({ + claim_tx: + '0x51d247094346142f780378bfb82a1e54b152db5d4035ec4e6937c531c47b0145', + }); + + const pendingInclusion = data.all_steps?.[1]; + expect(pendingInclusion?.result).toMatchObject({ + certificate_id: + '0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f', + previous_ler: + '0xfd107fe3ba1c4de7139e4ca5d666ec90a7df9698c926f585611eac31ce13192f', + }); + }); + }); + + describe('L2->L2 typology (7 steps)', () => { + it('parses a mid-flight run (tracker_l2l2_running.json) with the WaitingGERInjection step present', async () => { + mockFetchOnce(loadFixture('tracker_l2l2_running.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.bridge_status?.bridge_type).toBe('L2->L2'); + expect(data.all_steps).toHaveLength(7); + expect(data.all_steps?.map((s) => s.step_name)).toEqual([ + 'WaitingLERUpdate', + 'PendingInclusion', + 'CertificatePending', + 'WaitL1SettledGER', + 'WaitingGERInjection', + 'WaitingClaim', + 'Claimed', + ]); + expect(data.all_steps?.[4]?.step_name).toBe('WaitingGERInjection'); + expect(data.all_steps?.[4]?.status).toBe('inProgress'); + }); + + it('parses the terminal `finished` route (tracker_l2l2_finished.json) with correct step count/order/results', async () => { + mockFetchOnce(loadFixture('tracker_l2l2_finished.json'), 200); + const data = await client.getBridgeTracking('0xirrelevant'); + + expect(data.tracking_status).toBe('finished'); + expect(data.step_index).toBe(6); + expect(data.all_steps).toHaveLength(7); + + const gerInjection = data.all_steps?.[4]; + expect(gerInjection?.step_name).toBe('WaitingGERInjection'); + expect(gerInjection?.result).toEqual({ + ger: '0x6989b12606017b91d6defe2184415b5071fb7004e8daee4b3b82efd5e54045ff', + }); + + const waitingClaim = data.all_steps?.[5]; + expect(waitingClaim?.result).toMatchObject({ + claim_tx: + '0xea2424b0837070a37feba683b1994357fb92bc3b55116aae528a0f777d7c937c', + }); + + const claimed = data.all_steps?.[6]; + expect(claimed?.status).toBe('done'); + expect(claimed?.result).toBeUndefined(); + }); + }); + + describe('400 ErrorData (tracker error shape, not the bridge-service {"error"} shape)', () => { + it('throws AggkitApiError with the {code,message} body parsed as the error message', async () => { + mockFetchOnce(loadFixture('tracker_error_400.json'), 400); + + let caught: unknown; + try { + await client.getBridgeTracking('not-a-valid-hash'); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(AggkitApiError); + expect((caught as AggkitApiError).httpStatus).toBe(400); + expect((caught as AggkitApiError).message).toBe( + 'invalid tx_hash parameter' + ); + expect((caught as AggkitApiError).endpoint).toBe( + '/tracker/v1/network/{network_id}/tx/{tx_hash}' + ); + }); + }); +}); + +describe('AggkitBridgeAggregator.getBridgeTracking', () => { + const L2_1_URL = 'http://127.0.0.1:40001'; + const L2_2_URL = 'http://127.0.0.1:40002'; + let aggregator: AggkitBridgeAggregator; + + beforeEach(() => { + global.fetch = vi.fn(); + aggregator = new AggkitBridgeAggregator({ + networks: { 1: L2_1_URL, 2: L2_2_URL }, + }); + }); + + it('routes network 0 (L1) through the first configured L2 client but puts network 0 in the URL path', async () => { + mockFetchOnce(loadFixture('tracker_l1l2_finished.json'), 200); + const hash = + '0x64b65138996aae61811dac45f10c2baddbf0ab5aae9ef587766b92a23c85791e'; + + const data: AggkitTrackingData = await aggregator.getBridgeTracking( + 0, + hash + ); + + expect(data.tracking_status).toBe('finished'); + // Hits the network-1-configured client's base URL (first configured + // network — L1 has no dedicated instance)... + expect(lastFetchUrl()).toContain(L2_1_URL); + // ...but the URL path itself says network 0, not network 1. + expect(lastFetchUrl()).toBe(`${L2_1_URL}/tracker/v1/network/0/tx/${hash}`); + }); + + it('routes a non-L1 network directly to its own configured client, with that networkId in the URL path', async () => { + mockFetchOnce(loadFixture('tracker_l2l2_finished.json'), 200); + const hash = + '0x66a20ab10e92748f7ee30f9a487e262a673b790df365bf3067a59c8b71fb2fe8'; + + const data = await aggregator.getBridgeTracking(1, hash); + + expect(data.bridge_status?.bridge_type).toBe('L2->L2'); + expect(lastFetchUrl()).toBe(`${L2_1_URL}/tracker/v1/network/1/tx/${hash}`); + }); +}); diff --git a/src/aggkit/aggregator.ts b/src/aggkit/aggregator.ts new file mode 100644 index 0000000..0108cf9 --- /dev/null +++ b/src/aggkit/aggregator.ts @@ -0,0 +1,954 @@ +/** + * AggkitBridgeAggregator + * + * Multi-network aggregation + status derivation + token metadata over one + * `AggkitBridgeClient` per configured L2 network: activity fan-out/merge/ + * cursor, the status-derivation state machine, the cheap ready-to-claim + * count, claim-input orchestration and token-metadata composition. + */ + +import { AggkitBridgeClient } from './client'; +import { AggkitApiError } from './errors'; +import { chainRegistry } from '../native/chains/registry'; +import { ERC20 } from '../native'; +import { ZERO_ADDRESS } from '../constants'; +import type { + AggkitAggregatorConfig, + AggkitActivityPage, + AggkitBridge, + AggkitClaim, + AggkitClaimProof, + AggkitFailedNetwork, + AggkitPageCursor, + AggkitTokenMetadata, + AggkitTrackingData, + AggkitTransaction, + AggkitTransactionStatus, +} from './types'; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 200; + +/** Sentinel cursor value: this fan-out call is exhausted, never refetch it. */ +const EXHAUSTED = 0; + +function clampPageSize(pageSize: number | undefined): number { + const size = pageSize ?? DEFAULT_PAGE_SIZE; + return Math.min(size, MAX_PAGE_SIZE); +} + +function decodeCursor(cursor: string | undefined): AggkitPageCursor { + if (!cursor) { + return {}; + } + try { + const parsed = JSON.parse(cursor) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as AggkitPageCursor; + } + return {}; + } catch { + return {}; + } +} + +function isNativeTokenAddress(address: string): boolean { + return address.toLowerCase() === ZERO_ADDRESS.toLowerCase(); +} + +/** One page of a single paginated fan-out call. */ +interface CallPageResult { + items: T[]; + count: number; + /** Next 1-based page number to request, or `undefined` if exhausted. */ + nextPage: number | undefined; +} + +/** + * Runs one fan-out call honoring the composite cursor: if this call was + * previously marked exhausted (`EXHAUSTED` sentinel), skip it entirely; + * otherwise fetch the page recorded in the cursor (default 1) and compute + * whether more pages remain. + */ +async function runPaginatedCall( + cursorState: AggkitPageCursor, + key: string, + pageSize: number, + fetchPage: (pageNumber: number) => Promise<{ items: T[]; count: number }> +): Promise> { + const stored = cursorState[key]; + if (stored === EXHAUSTED) { + return { items: [], count: 0, nextPage: undefined }; + } + + const pageNumber = stored ?? 1; + const { items, count } = await fetchPage(pageNumber); + const hasMore = pageNumber * pageSize < count; + + return { items, count, nextPage: hasMore ? pageNumber + 1 : undefined }; +} + +function mergeClaimsMap( + dest: Map>, + networkId: number, + src: Map +): void { + const existing = dest.get(networkId) ?? new Map(); + for (const [key, value] of src) { + existing.set(key, value); + } + dest.set(networkId, existing); +} + +/** One bridge row plus the networkId of the aggkit instance it was fetched from. */ +interface FetchedBridgeRow { + bridge: AggkitBridge; + /** The configured L2 network whose instance this row was fetched via. */ + sourceInstanceNetworkId: number; + /** + * The network whose LOCAL EXIT TREE recorded this deposit — i.e. the + * `network_id` the fan-out call itself used, NOT `bridge.origin_network`. + * Call A (`getBridges({ networkId: n })`) + * rows are recorded on n's own tree, so `recordingNetworkId === n`. Call B + * (`getBridges({ networkId: 0, networkIds: [n] })`) rows are recorded on + * L1's tree, so `recordingNetworkId === 0`. + * + * This coincides with `bridge.origin_network` for genuine L1-origin + * deposits (call B, origin 0) and genuine L2-origin tokens (call A, + * origin n) — but NOT for withdrawals of an L2's native gas token, which + * mirrors L1 ETH (`origin_network` is always 0) yet is recorded on the + * L2's OWN tree (call A, so `recordingNetworkId === n`). Use this field, + * not `bridge.origin_network`, when probing `/l1-info-tree-index`. + */ + recordingNetworkId: number; +} + +/** Result of fanning out the four calls (A-D) for one configured network. */ +interface NetworkFanoutResult { + networkId: number; + bridgeRowsA: AggkitBridge[]; + bridgeRowsB: AggkitBridge[]; + /** Claims landed on this network (destination = networkId). */ + claimsHere: Map; + /** Claims landed on L1, as seen via this network's own L1 syncer. */ + claimsL1: Map; + /** Sum of A.count + B.count, for `pagination.total`. */ + totalBridgesCount: number; + /** Updated cursor entries for this network's 4 fan-out calls. */ + nextCursorPatch: AggkitPageCursor; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Builds a failed-network attribution from a caught error. */ +function toFailedNetwork( + networkId: number, + error: unknown +): AggkitFailedNetwork { + return { + networkId, + error: errorMessage(error), + ...(error instanceof AggkitApiError + ? { httpStatus: error.httpStatus } + : {}), + }; +} + +/** + * Result of resolving the destination-injected L1-info-tree leaf a deposit's + * `/claim-proof` must be built against (a proof built on the deposit's own + * source index reverts GlobalExitRootInvalid until the destination has + * injected a GER at-or-after it). + */ +type InjectedLeafResolution = + | { kind: 'resolved'; leafIndex: number } // injected (or destination is L1) + | { kind: 'not-injected' } // 404 "not injected" + | { kind: 'unknown'; reason: string }; // no client for destination + +export class AggkitBridgeAggregator { + private readonly clients: Map; + + constructor(config: AggkitAggregatorConfig) { + this.clients = new Map(); + for (const [key, baseUrl] of Object.entries(config.networks)) { + const networkId = Number(key); + this.clients.set( + networkId, + new AggkitBridgeClient({ + baseUrl, + networkId, + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), + ...(config.retries !== undefined ? { retries: config.retries } : {}), + ...(config.retryDelay !== undefined + ? { retryDelay: config.retryDelay } + : {}), + }) + ); + } + } + + /** Returns the single-network client for `networkId`; throws if unconfigured. */ + clientFor(networkId: number): AggkitBridgeClient { + const client = this.clients.get(networkId); + if (!client) { + throw new Error( + `AggkitBridgeAggregator: no client configured for network ${networkId}. ` + + `Configured networks: ${this.listNetworkIds().join(', ') || '(none)'}` + ); + } + return client; + } + + /** + * Like `clientFor`, but routes L1 (`networkId === 0`) through a configured + * L2 instance — L1 has no dedicated aggkit instance; every configured L2 + * instance's embedded L1 syncer serves `network_id=0` queries identically. + * Mirrors the routing `getClaimInputs` already applies + * for the L1-origin case, generalized for callers (like `getTokenMetadata`) + * that only have a bare `networkId`, not an explicit origin/destination + * pair to pick a specific instance from. + */ + private clientForNetworkOrL1(networkId: number): AggkitBridgeClient { + if (networkId !== 0) { + return this.clientFor(networkId); + } + const [firstConfigured] = this.listNetworkIds(); + if (firstConfigured === undefined) { + throw new Error( + `AggkitBridgeAggregator: no client configured for network 0. L1 has no ` + + `dedicated instance and requires at least one configured L2 network to ` + + `route through. Configured networks: (none)` + ); + } + return this.clientFor(firstConfigured); + } + + listNetworkIds(): number[] { + return Array.from(this.clients.keys()); + } + + /** + * Fan-out + join + status derivation. Never rejects if + * at least one configured network's fan-out succeeds; rejects only if ALL + * fan-outs fail (or no networks are configured). + */ + async getActivity(params: { + fromAddress: string; + pageSize?: number; + cursor?: string; + order?: 'asc' | 'desc'; + }): Promise { + const pageSize = clampPageSize(params.pageSize); + const order = params.order ?? 'desc'; + const cursorState = decodeCursor(params.cursor); + const networkIds = this.listNetworkIds(); + + const settled = await Promise.allSettled( + networkIds.map((networkId) => + this.fetchNetworkFanout( + networkId, + params.fromAddress, + cursorState, + pageSize + ) + ) + ); + + const failedNetworks: AggkitFailedNetwork[] = []; + const claimsByNetwork = new Map>(); + const rows: FetchedBridgeRow[] = []; + const nextCursor: AggkitPageCursor = { ...cursorState }; + let total = 0; + let anySucceeded = false; + + settled.forEach((result, index) => { + const networkId = networkIds[index] as number; + + if (result.status === 'rejected') { + const error = result.reason; + failedNetworks.push({ + networkId, + error: errorMessage(error), + ...(error instanceof AggkitApiError + ? { httpStatus: error.httpStatus } + : {}), + }); + return; + } + + anySucceeded = true; + const fanout = result.value; + + for (const bridge of fanout.bridgeRowsA) { + rows.push({ + bridge, + sourceInstanceNetworkId: networkId, + recordingNetworkId: networkId, + }); + } + for (const bridge of fanout.bridgeRowsB) { + rows.push({ + bridge, + sourceInstanceNetworkId: networkId, + recordingNetworkId: 0, + }); + } + + mergeClaimsMap(claimsByNetwork, networkId, fanout.claimsHere); + mergeClaimsMap(claimsByNetwork, 0, fanout.claimsL1); + + total += fanout.totalBridgesCount; + Object.assign(nextCursor, fanout.nextCursorPatch); + }); + + if (!anySucceeded && networkIds.length > 0) { + throw new Error( + `AggkitBridgeAggregator.getActivity: all configured networks failed: ` + + failedNetworks.map((f) => `${f.networkId}: ${f.error}`).join('; ') + ); + } + + // Dedupe by bridge_hash (unique per event). + const dedupedByHash = new Map(); + for (const row of rows) { + if (!dedupedByHash.has(row.bridge.bridge_hash)) { + dedupedByHash.set(row.bridge.bridge_hash, row); + } + } + const deduped = Array.from(dedupedByHash.values()); + + deduped.sort((a, b) => + order === 'asc' + ? a.bridge.block_timestamp - b.bridge.block_timestamp + : b.bridge.block_timestamp - a.bridge.block_timestamp + ); + + // Per-row Tier-2 probe failures are collected separately from + // fan-out failures so a failing destination/recording network degrades + // this row to a conservative status instead of rejecting the whole call. + const probeFailures: AggkitFailedNetwork[] = []; + const onNetworkError = (failure: AggkitFailedNetwork): void => { + probeFailures.push(failure); + }; + + const data = await Promise.all( + deduped.map((row) => + this.toTransaction( + row.bridge, + row.sourceInstanceNetworkId, + row.recordingNetworkId, + claimsByNetwork, + onNetworkError + ) + ) + ); + + // Merge probe failures into failedNetworks, deduped by networkId (keep + // the first message — fan-out failures, collected above, take priority + // over a later per-row probe failure for the same network). + const seenFailedNetworkIds = new Set( + failedNetworks.map((f) => f.networkId) + ); + for (const failure of probeFailures) { + if (!seenFailedNetworkIds.has(failure.networkId)) { + seenFailedNetworkIds.add(failure.networkId); + failedNetworks.push(failure); + } + } + + const anyMore = Object.values(nextCursor).some((v) => v !== EXHAUSTED); + + return { + data, + pagination: { + total, + limit: pageSize, + ...(anyMore + ? { nextStartAfterCursor: JSON.stringify(nextCursor) } + : {}), + }, + failedNetworks, + }; + } + + /** + * Cheap ready-to-claim count: one bounded (single, large) + * page of bridges+claims per configured network (Tier 1) to build the + * unclaimed set, then Tier-2 `/l1-info-tree-index` probes bounded to that + * unclaimed set only — never a full activity scan. + */ + async getReadyToClaimCount(params: { fromAddress: string }): Promise { + const networkIds = this.listNetworkIds(); + const pageSize = MAX_PAGE_SIZE; + + const settled = await Promise.allSettled( + networkIds.map((networkId) => + this.fetchNetworkFanout(networkId, params.fromAddress, {}, pageSize) + ) + ); + + const claimsByNetwork = new Map>(); + const allRows: FetchedBridgeRow[] = []; + let anySucceeded = false; + + settled.forEach((result, index) => { + if (result.status === 'rejected') { + return; + } + anySucceeded = true; + const networkId = networkIds[index] as number; + const fanout = result.value; + + mergeClaimsMap(claimsByNetwork, networkId, fanout.claimsHere); + mergeClaimsMap(claimsByNetwork, 0, fanout.claimsL1); + + for (const bridge of fanout.bridgeRowsA) { + allRows.push({ + bridge, + sourceInstanceNetworkId: networkId, + recordingNetworkId: networkId, + }); + } + for (const bridge of fanout.bridgeRowsB) { + allRows.push({ + bridge, + sourceInstanceNetworkId: networkId, + recordingNetworkId: 0, + }); + } + }); + + if (!anySucceeded && networkIds.length > 0) { + throw new Error( + 'AggkitBridgeAggregator.getReadyToClaimCount: all configured networks failed' + ); + } + + const seenHashes = new Set(); + const unclaimed = allRows.filter((row) => { + if (seenHashes.has(row.bridge.bridge_hash)) { + return false; + } + seenHashes.add(row.bridge.bridge_hash); + + const claims = claimsByNetwork.get(row.bridge.destination_network); + return !claims?.has(row.bridge.global_index); + }); + + const readyFlags = await Promise.all( + unclaimed.map(async (row) => { + // Guard the whole per-row probe: a failing + // network's probe must under-count the badge, not reject the whole + // count call. `useReadyToClaimCount` has no `failedNetworks` surface + // by design (`app/hooks/useReadyToClaimCount.ts`). + try { + const probe = await this.clientFor( + row.sourceInstanceNetworkId + ).getL1InfoTreeIndex({ + networkId: row.recordingNetworkId, + depositCount: row.bridge.deposit_count, + }); + + if (probe === null) { + return false; + } + + // Tier-1 membership only checked page 1 of /claims — bounded, cheap, + // and wrong once a network's total claims exceed one page (bug b, + // see the claims-pagination correctness regression test). + // For candidates that passed the leaf-included probe (i.e. that + // would otherwise be counted READY_TO_CLAIM), confirm with a + // targeted per-candidate query before counting them. + const confirmedClaim = await this.confirmClaimed( + row.bridge, + row.sourceInstanceNetworkId + ); + return confirmedClaim === null; + } catch { + return false; + } + }) + ); + + return readyFlags.filter(Boolean).length; + } + + /** + * Targeted confirmation backstop for bug (b): page-1 `/claims` membership + * (Tier 1, cheap fast path) can miss a deposit's claim once a network's + * total claim count exceeds one page, mis-deriving already-CLAIMED + * deposits as READY_TO_CLAIM. `/claims[].from_address` is always "" in + * aggkit responses so claims cannot be filtered by address — but + * `/claims?global_index=` returns the exact matching claim if one + * exists, regardless of which page it would have landed on. This is only + * called for candidates that already passed the Tier-2 leaf-included probe + * (i.e. would otherwise be marked READY_TO_CLAIM), so the extra request + * count per page is bounded to that small candidate set, not the whole + * claims tree. + */ + private async confirmClaimed( + bridge: AggkitBridge, + sourceInstanceNetworkId: number + ): Promise { + const destinationNetworkId = bridge.destination_network; + // Claims recorded on L1 (destination 0) are visible via any configured + // instance's own embedded L1 syncer — use the instance + // this row was already fetched through. Claims recorded on a configured + // L2 network must be queried via that network's own instance (aggkit + // rejects `network_id`s it doesn't serve). + const client = + destinationNetworkId === 0 + ? this.clientFor(sourceInstanceNetworkId) + : this.clients.get(destinationNetworkId); + + if (!client) { + // No configured instance can confirm this destination network's + // claims (e.g. an unconfigured L2->L2 destination). + // Not more precise than the Tier-1/Tier-2 result already computed. + return null; + } + + try { + const result = await client.getClaims({ + networkId: destinationNetworkId, + globalIndex: bridge.global_index, + }); + return result.claims[0] ?? null; + } catch { + // The confirmation query is a correctness backstop, not a hard + // dependency — if it fails, fall back to the Tier-2 probe result + // rather than failing the whole activity/count call. + return null; + } + } + + /** + * Resolves the L1-info-tree index that `/claim-proof` must be built against for a + * deposit landing on `destinationNetworkId`. + * - destinationNetworkId === 0 -> { resolved, sourceL1InfoTreeIndex } (no injection step) + * - destination client missing -> { unknown } (caller keeps legacy behaviour) + * - 404 "not injected" -> { not-injected } + * - 200 -> { resolved, leaf.l1_info_tree_index } // >= source index + * Probe errors are NOT swallowed here; they propagate so callers can attribute them + * to `failedNetworks`. + */ + private async resolveInjectedLeafIndex(params: { + destinationNetworkId: number; + sourceL1InfoTreeIndex: number; + }): Promise { + const { destinationNetworkId, sourceL1InfoTreeIndex } = params; + + // L1 has no injection step — the handler returns the leaf AT the index + // for network_id=0. + if (destinationNetworkId === 0) { + return { kind: 'resolved', leafIndex: sourceL1InfoTreeIndex }; + } + + const client = this.clients.get(destinationNetworkId); + if (!client) { + // Mirrors the existing confirmClaimed fallback: an + // unconfigured destination L2 keeps today's (possibly reverting) + // behaviour rather than regressing into a permanent non-actionable + // state. The on-chain revert remains the backstop. + return { + kind: 'unknown', + reason: `no client configured for destination network ${destinationNetworkId}`, + }; + } + + const leaf = await client.getInjectedL1InfoLeaf({ + networkId: destinationNetworkId, + leafIndex: sourceL1InfoTreeIndex, + }); + + if (leaf === null) { + return { kind: 'not-injected' }; + } + + return { kind: 'resolved', leafIndex: leaf.l1_info_tree_index }; + } + + /** + * Single-tx claim inputs: resolves the deposit's own + * L1-info-tree index on the SOURCE (recording) network, then — for an L2 + * destination — the destination's INJECTED leaf index for that value, then + * the claim proof against the injected index. Throws `AggkitApiError` if + * not yet claimable (source not settled, or destination GER not injected). + */ + async getClaimInputs(params: { + originNetworkId: number; + destinationNetworkId: number; + depositCount: number; + }): Promise<{ + /** L1-info-tree index passed to /claim-proof: the DESTINATION-INJECTED index when + * destinationNetworkId !== 0, else the source index. */ + leafIndex: number; + proof: AggkitClaimProof; + /** NEW (additive): the deposit's own index from /l1-info-tree-index. Equals + * `leafIndex` when the destination is L1 or when injection was exact. Diagnostics + * for S8 smoke / S10 evidence. */ + sourceL1InfoTreeIndex: number; + }> { + const { originNetworkId, destinationNetworkId, depositCount } = params; + + // L1-origin (network 0) has no dedicated instance; its L1 info tree is + // read via the destination L2's instance. L2-origin + // deposits are read via their own origin instance. + const client = + originNetworkId === 0 + ? this.clientFor(destinationNetworkId) + : this.clientFor(originNetworkId); + + const sourceL1InfoTreeIndex = await client.getL1InfoTreeIndex({ + networkId: originNetworkId, + depositCount, + }); + + if (sourceL1InfoTreeIndex === null) { + throw new AggkitApiError({ + message: + `Deposit (originNetworkId=${originNetworkId}, depositCount=${depositCount}) ` + + `is not yet claimable: not included on the L1 info tree`, + httpStatus: 500, + endpoint: '/l1-info-tree-index', + }); + } + + const resolution = await this.resolveInjectedLeafIndex({ + destinationNetworkId, + sourceL1InfoTreeIndex, + }); + + let leafIndex: number; + if (resolution.kind === 'not-injected') { + throw new AggkitApiError({ + message: + `Deposit (originNetworkId=${originNetworkId}, depositCount=${depositCount}) ` + + `is not yet claimable: destination network ${destinationNetworkId} has not ` + + `injected the global exit root for L1-info-tree leaf ${sourceL1InfoTreeIndex}`, + httpStatus: 404, + endpoint: '/injected-l1-info-leaf', + }); + } else if (resolution.kind === 'unknown') { + // Legacy behaviour: the on-chain revert is the backstop. + leafIndex = sourceL1InfoTreeIndex; + } else { + leafIndex = resolution.leafIndex; + } + + const proof = await client.getClaimProof({ + networkId: originNetworkId, + leafIndex, + depositCount, + }); + + return { leafIndex, proof, sourceL1InfoTreeIndex }; + } + + /** + * Token metadata composition: native check, then + * token-mappings resolution (best-effort) + on-chain `ERC20.getMetadata()` + * reads. Output shape matches the UI's existing `TokenMetadata` contract. + */ + async getTokenMetadata( + tokenAddress: string, + networkId: number + ): Promise { + // L1 (networkId 0) has no dedicated aggkit instance — route through a + // configured L2 instance, same as `getClaimInputs`. + const client = this.clientForNetworkOrL1(networkId); + // FIXED: `getChainByNetworkId` previously returned the + // first insertion-order match, and Ethereum mainnet is pre-seeded at + // networkId 0 ahead of any other networkId-0 chain (e.g. a devnet L1). + // For NATIVE token metadata on networkId 0 this reported mainnet's + // nativeCurrency/rpcUrl instead of the intended chain. `ChainRegistry` + // now tracks which chainIds are built-in defaults and, on a networkId + // collision, always prefers a consumer-registered chain over a default + // one — independent of registration order (see + // `src/native/chains/registry.ts`'s `getChainByNetworkId` precedence + // note). Consumers that register their L1 (e.g. devnet, networkId 0) no + // longer risk resolving the SDK's default mainnet entry. + const chain = chainRegistry.getChainByNetworkId(networkId); + + if (isNativeTokenAddress(tokenAddress)) { + return { + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + tokenAddress: ZERO_ADDRESS, + network: networkId, + }; + } + + const mappingsResult = await client.getTokenMappings({ + networkId, + originTokenAddress: tokenAddress, + }); + const mapping = mappingsResult.token_mappings[0]; + + const erc20 = new ERC20({ + tokenAddress, + rpcUrl: chain.rpcUrl, + chainId: chain.chainId, + }); + const onChain = await erc20.getMetadata(); + + return { + name: onChain.name, + symbol: onChain.symbol, + decimals: onChain.decimals, + tokenAddress, + network: networkId, + ...(onChain.totalSupply !== undefined + ? { totalSupply: onChain.totalSupply } + : {}), + ...(mapping + ? { + originTokenAddress: mapping.origin_token_address, + originTokenNetwork: mapping.origin_network, + wrappedTokenAddressV1: mapping.wrapped_token_address, + } + : {}), + }; + } + + /** + * Bridge tracker lookup (aggkit `tracker/v1`, + * `docs/bridgetracker/API.md`): registers (if not already) and returns + * `txHash`'s `AggkitTrackingData` from the aggkit instance serving + * `networkId`. Routes L1 (`networkId === 0`) through a configured L2 + * instance, same as `getTokenMetadata` — L1 has no dedicated instance — + * and always passes `networkId` through explicitly to + * `AggkitBridgeClient.getBridgeTracking`'s URL path, since the routed- + * through L2 instance's own `networkId` is not 0. + * + * See `AggkitBridgeClient.getBridgeTracking` for terminal-state/polling + * guidance, and the `AggkitTrackingData`/`AggkitBridgeStepPath` etc. type + * docs in `types.ts` for the full wire-format reference — + * `tracking_status`/`bridge_type`/`step_name`/step `status` are bare + * string unions on the wire, not numeric + `_string` companion pairs, + * matching aggkit's rc5-corrected `API.md` (agglayer/aggkit#1781, fixed + * in PR #1784); the wire format itself has been unchanged since rc4. + */ + async getBridgeTracking( + networkId: number, + txHash: string + ): Promise { + const client = this.clientForNetworkOrL1(networkId); + return client.getBridgeTracking(txHash, networkId); + } + + /** Runs the four fan-out calls (A-D) for a single configured network. */ + private async fetchNetworkFanout( + networkId: number, + fromAddress: string, + cursorState: AggkitPageCursor, + pageSize: number + ): Promise { + const client = this.clientFor(networkId); + + const keyA = `${networkId}:bridgesOrigin`; + const keyB = `${networkId}:bridgesL1`; + const keyC = `${networkId}:claimsHere`; + const keyD = `${networkId}:claimsL1`; + + const [a, b, c, d] = await Promise.all([ + // A. L2-origin bridges (n -> L1, n -> other L2). + runPaginatedCall(cursorState, keyA, pageSize, (pageNumber) => + client + .getBridges({ + networkId, + fromAddress, + pageNumber, + pageSize, + }) + .then((r) => ({ items: r.bridges, count: r.count })) + ), + // B. L1-origin bridges destined to n. + runPaginatedCall(cursorState, keyB, pageSize, (pageNumber) => + client + .getBridges({ + networkId: 0, + networkIds: [networkId], + fromAddress, + pageNumber, + pageSize, + }) + .then((r) => ({ items: r.bridges, count: r.count })) + ), + // C. Claims on n (settles L1->n and other->n). + runPaginatedCall(cursorState, keyC, pageSize, (pageNumber) => + client + .getClaims({ networkId, pageNumber, pageSize }) + .then((r) => ({ items: r.claims, count: r.count })) + ), + // D. Claims on L1, as recorded via n's own L1 syncer (settles n->L1). + runPaginatedCall(cursorState, keyD, pageSize, (pageNumber) => + client + .getClaims({ networkId: 0, pageNumber, pageSize }) + .then((r) => ({ items: r.claims, count: r.count })) + ), + ]); + + const claimsHere = new Map(); + for (const claim of c.items) { + claimsHere.set(claim.global_index, claim); + } + + const claimsL1 = new Map(); + for (const claim of d.items) { + claimsL1.set(claim.global_index, claim); + } + + return { + networkId, + bridgeRowsA: a.items, + bridgeRowsB: b.items, + claimsHere, + claimsL1, + totalBridgesCount: a.count + b.count, + nextCursorPatch: { + [keyA]: a.nextPage ?? EXHAUSTED, + [keyB]: b.nextPage ?? EXHAUSTED, + [keyC]: c.nextPage ?? EXHAUSTED, + [keyD]: d.nextPage ?? EXHAUSTED, + }, + }; + } + + /** + * Joins one bridge row into a UI `Transaction`, deriving `status`: Tier 1 + * (claims-set membership, free/batch) first, then Tier 2a (`/l1-info-tree-index` + * probe, bounded to unclaimed rows only), then — for an L2 destination + * only — Tier 2b (the destination-injected-GER gate). + * + * The Tier-2a probe is keyed by `recordingNetworkId` — the network whose + * local exit tree actually recorded this deposit (call A vs call B of + * `fetchNetworkFanout`, see `FetchedBridgeRow`) — NOT `bridge.origin_network`. + * These coincide for genuine L1-origin deposits and genuine L2-origin + * tokens, but diverge for withdrawals of an L2's native gas token, + * where `origin_network` is always 0 but the deposit is + * recorded on the L2's own tree. + * + * Both Tier-2 probes are guarded: a throw is + * reported to `onNetworkError` and the row derives a conservative, + * non-actionable status (`BRIDGED` for a Tier-2a failure, `LEAF_INCLUDED` + * for a Tier-2b failure) instead of rejecting the whole `getActivity` call. + */ + private async toTransaction( + bridge: AggkitBridge, + sourceInstanceNetworkId: number, + recordingNetworkId: number, + claimsByNetwork: Map>, + onNetworkError: (failure: AggkitFailedNetwork) => void + ): Promise { + const destinationClaims = claimsByNetwork.get(bridge.destination_network); + let matchedClaim = destinationClaims?.get(bridge.global_index); + + let status: AggkitTransactionStatus; + let leafIndexForProof: number | undefined; + + if (matchedClaim) { + status = 'CLAIMED'; + } else { + const client = this.clientFor(sourceInstanceNetworkId); + let probe: number | null; + try { + probe = await client.getL1InfoTreeIndex({ + networkId: recordingNetworkId, + depositCount: bridge.deposit_count, + }); + } catch (error) { + // Tier-2a throw: conservative non-actionable status, attribute the + // failure to the recording network. + onNetworkError(toFailedNetwork(recordingNetworkId, error)); + probe = null; + } + + if (probe !== null) { + // Tier-1 membership only covers page 1 of /claims (bounded, cheap + // fast path) and can miss this deposit's claim once a network's + // total claims exceed one page — mis-deriving READY_TO_CLAIM for an + // already-CLAIMED deposit (bug b). Confirm with a targeted + // global_index query before committing to READY_TO_CLAIM. + const confirmedClaim = await this.confirmClaimed( + bridge, + sourceInstanceNetworkId + ); + if (confirmedClaim) { + matchedClaim = confirmedClaim; + status = 'CLAIMED'; + } else if (bridge.destination_network === 0) { + // L2->L1: no injection step (the destination-injected-GER gate + // applies only to L2 destinations). + status = 'READY_TO_CLAIM'; + leafIndexForProof = probe; + } else { + try { + const resolution = await this.resolveInjectedLeafIndex({ + destinationNetworkId: bridge.destination_network, + sourceL1InfoTreeIndex: probe, + }); + + if (resolution.kind === 'not-injected') { + status = 'LEAF_INCLUDED'; + } else if (resolution.kind === 'unknown') { + status = 'READY_TO_CLAIM'; + leafIndexForProof = probe; + } else { + status = 'READY_TO_CLAIM'; + leafIndexForProof = resolution.leafIndex; + } + } catch (error) { + // Tier-2b throw: conservative non-actionable status, attribute + // the failure to the destination network. + onNetworkError(toFailedNetwork(bridge.destination_network, error)); + status = 'LEAF_INCLUDED'; + } + } + } else { + status = 'BRIDGED'; + } + } + + return { + hubUID: bridge.bridge_hash, + txSender: bridge.txn_sender, + fromAddress: bridge.from_address || bridge.txn_sender, + receiverAddress: bridge.destination_address, + // Display counterpart of the recording-network status-derivation fix: + // use the RECORDING network, not `bridge.origin_network`. For + // an L2-native-gas-token withdrawal `origin_network` is always 0 (the + // asset origin, L1 ETH) even though the row is recorded on the L2's + // own local exit tree — `recordingNetworkId` already captures this + // distinction (call A vs call B of `fetchNetworkFanout`). + sourceNetwork: recordingNetworkId, + destinationNetwork: bridge.destination_network, + amount: bridge.amount, + status, + lastUpdatedAt: bridge.block_timestamp, + bridgeHash: bridge.bridge_hash, + metadata: bridge.metadata, + leafType: String(bridge.leaf_type), + depositCount: bridge.deposit_count, + transactionIndex: bridge.block_pos, + transactionHash: bridge.tx_hash, + blockNumber: bridge.block_num, + globalIndex: bridge.global_index, + originTokenAddress: bridge.origin_address, + originTokenNetwork: bridge.origin_network, + timestamp: bridge.block_timestamp, + leafIndex: bridge.deposit_count, + ...(leafIndexForProof !== undefined ? { leafIndexForProof } : {}), + ...(matchedClaim + ? { + claimTransactionHash: matchedClaim.tx_hash, + claimTimestamp: matchedClaim.block_timestamp, + claimBlockNumber: matchedClaim.block_num, + } + : {}), + }; + } +} diff --git a/src/aggkit/client.ts b/src/aggkit/client.ts new file mode 100644 index 0000000..62a3ba8 --- /dev/null +++ b/src/aggkit/client.ts @@ -0,0 +1,485 @@ +/** + * AggkitBridgeClient + * + * Single-network typed client for one aggkit `bridge/v1` REST instance. + * One aggkit REST instance is bound to exactly one L2 network; this + * client wraps the endpoints consumed by the bridge UI: + * bridges, claims, l1-info-tree-index, claim-proof, token-mappings, + * sync-status (and the root health check). + */ + +import { AggkitApiError } from './errors'; +import { fetchRawText, type RawFetchConfig } from './httpRaw'; +import { quoteGlobalIndex } from './parsing'; +import type { + AggkitBridgeClientConfig, + AggkitBridgesResult, + AggkitClaimsResult, + AggkitClaimProof, + AggkitL1InfoTreeLeaf, + AggkitTokenMappingsResult, + AggkitSyncStatus, + AggkitErrorBody, + AggkitHealthResponse, + AggkitTrackingData, + AggkitTrackerErrorData, +} from './types'; + +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; + +/** `page_size` max enforced by aggkit (`utils.go` `MaxPageSize`). */ +const MAX_PAGE_SIZE = 200; +/** `network_ids` max enforced by aggkit (`utils.go` `MaxNetworkIDs`). */ +const MAX_NETWORK_IDS = 5; + +/** + * Substrings of the aggkit `/l1-info-tree-index` 500 error message that mean + * "not claimable yet" rather than a genuine failure: + * - "this bridge has not been included on the L1 Info Tree yet" + * - "not found" (L2-origin deposits, pre-settlement) + */ +const L1_INFO_TREE_INDEX_NOT_READY_PATTERNS = [ + 'not been included', + 'not found', +]; + +/** + * Substrings of the `/injected-l1-info-leaf` 404 body that mean "destination GER + * not injected yet" rather than a routing/config failure. MUST be message-matched: + * aggkit-proxy also answers 404 with `{"error":"bridge service url not found for + * network: network N"}` (fixtures/error_404_unknown_network.json), and treating + * that as "not ready" would strand rows in LEAF_INCLUDED forever. + */ +const INJECTED_L1_INFO_LEAF_NOT_READY_PATTERNS = ['not injected']; + +type QueryValue = string | number | boolean | number[] | undefined; + +/** + * Strips trailing `/` characters from `url`. + * + * Implemented as a manual backward scan rather than a regex (e.g. `/\/+$/`) + * because `baseUrl` is library/consumer-supplied input: a regex quantifier + * anchored at the end of the string is flagged by CodeQL as a potential + * ReDoS source (polynomial worst-case matching cost), even though this + * particular pattern isn't exploitable in practice. A plain scan is linear + * by construction and carries no such risk. + */ +function stripTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url.charAt(end - 1) === '/') { + end--; + } + return url.slice(0, end); +} + +export class AggkitBridgeClient { + /** The L2 network id this aggkit instance serves. */ + public readonly networkId: number; + + private readonly rootUrl: string; + private readonly bridgeApiUrl: string; + private readonly trackerApiUrl: string; + private readonly fetchConfig: RawFetchConfig; + + constructor(config: AggkitBridgeClientConfig) { + this.networkId = config.networkId; + this.rootUrl = stripTrailingSlashes(config.baseUrl); + this.bridgeApiUrl = `${this.rootUrl}/bridge/v1`; + this.trackerApiUrl = `${this.rootUrl}/tracker/v1`; + this.fetchConfig = { + timeout: config.timeout ?? DEFAULT_TIMEOUT, + retries: config.retries ?? DEFAULT_RETRIES, + retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY, + }; + } + + async getBridges(params: { + networkId: 0 | number; + fromAddress?: string; + depositCount?: number; + networkIds?: number[]; + pageNumber?: number; + pageSize?: number; + }): Promise { + this.assertPageSize(params.pageSize); + this.assertNetworkIds(params.networkIds); + + const query = this.buildQuery({ + network_id: params.networkId, + from_address: params.fromAddress, + deposit_count: params.depositCount, + network_ids: params.networkIds, + page_number: params.pageNumber, + page_size: params.pageSize, + }); + + const { status, text } = await this.requestRaw('/bridges', query); + this.assertOk('/bridges', status, text); + + return JSON.parse(quoteGlobalIndex(text)) as AggkitBridgesResult; + } + + async getClaims(params: { + networkId: 0 | number; + globalIndex?: string; + networkIds?: number[]; + includeAllFields?: boolean; + pageNumber?: number; + pageSize?: number; + }): Promise { + this.assertPageSize(params.pageSize); + this.assertNetworkIds(params.networkIds); + + const query = this.buildQuery({ + network_id: params.networkId, + global_index: params.globalIndex, + network_ids: params.networkIds, + include_all_fields: params.includeAllFields, + page_number: params.pageNumber, + page_size: params.pageSize, + }); + + const { status, text } = await this.requestRaw('/claims', query); + this.assertOk('/claims', status, text); + + return JSON.parse(quoteGlobalIndex(text)) as AggkitClaimsResult; + } + + /** + * Returns the L1-info-tree index for `(networkId, depositCount)`, or + * `null` when aggkit reports the deposit is not yet included on the L1 + * info tree (its documented 500 branches) — see + * `L1_INFO_TREE_INDEX_NOT_READY_PATTERNS`. Any other error still throws. + */ + async getL1InfoTreeIndex(params: { + networkId: 0 | number; + depositCount: number; + }): Promise { + const query = this.buildQuery({ + network_id: params.networkId, + deposit_count: params.depositCount, + }); + + const { status, text } = await this.requestRaw( + '/l1-info-tree-index', + query + ); + + if (status >= 200 && status < 300) { + const value = Number(text.trim()); + if (Number.isNaN(value)) { + throw new AggkitApiError({ + message: `Unexpected non-numeric body for /l1-info-tree-index: ${text}`, + httpStatus: status, + endpoint: '/l1-info-tree-index', + body: text, + }); + } + return value; + } + + if (status === 500) { + const message = this.parseErrorMessage(text); + const lowerMessage = message.toLowerCase(); + const notReady = L1_INFO_TREE_INDEX_NOT_READY_PATTERNS.some((pattern) => + lowerMessage.includes(pattern) + ); + if (notReady) { + return null; + } + throw new AggkitApiError({ + message, + httpStatus: status, + endpoint: '/l1-info-tree-index', + body: text, + }); + } + + throw new AggkitApiError({ + message: this.parseErrorMessage(text), + httpStatus: status, + endpoint: '/l1-info-tree-index', + body: text, + }); + } + + async getClaimProof(params: { + networkId: 0 | number; + leafIndex: number; + depositCount: number; + }): Promise { + const query = this.buildQuery({ + network_id: params.networkId, + leaf_index: params.leafIndex, + deposit_count: params.depositCount, + }); + + const { status, text } = await this.requestRaw('/claim-proof', query); + this.assertOk('/claim-proof', status, text); + + return JSON.parse(text) as AggkitClaimProof; + } + + /** + * Destination-side GER-injection probe. + * For an L2 `networkId`, aggkit returns the leaf of the FIRST injected global exit + * root at or AFTER `leafIndex` (so `result.l1_info_tree_index >= leafIndex`). + * For `networkId === 0` it returns the leaf AT `leafIndex`. + * Returns `null` ONLY for the documented 404 "not injected" branch; every other + * non-2xx (incl. any other 404, e.g. the proxy's "bridge service url not found") + * throws `AggkitApiError`. + */ + async getInjectedL1InfoLeaf(params: { + networkId: 0 | number; + leafIndex: number; + }): Promise { + const query = this.buildQuery({ + network_id: params.networkId, + leaf_index: params.leafIndex, + }); + + const { status, text } = await this.requestRaw( + '/injected-l1-info-leaf', + query + ); + + if (status >= 200 && status < 300) { + return JSON.parse(text) as AggkitL1InfoTreeLeaf; + } + + if (status === 404) { + const message = this.parseErrorMessage(text); + const lowerMessage = message.toLowerCase(); + const notReady = INJECTED_L1_INFO_LEAF_NOT_READY_PATTERNS.some( + (pattern) => lowerMessage.includes(pattern) + ); + if (notReady) { + return null; + } + } + + throw new AggkitApiError({ + message: this.parseErrorMessage(text), + httpStatus: status, + endpoint: '/injected-l1-info-leaf', + body: text, + }); + } + + async getTokenMappings(params: { + networkId: number; + originTokenAddress?: string; + pageNumber?: number; + pageSize?: number; + }): Promise { + this.assertPageSize(params.pageSize); + + const query = this.buildQuery({ + network_id: params.networkId, + origin_token_address: params.originTokenAddress, + page_number: params.pageNumber, + page_size: params.pageSize, + }); + + const { status, text } = await this.requestRaw('/token-mappings', query); + this.assertOk('/token-mappings', status, text); + + return JSON.parse(text) as AggkitTokenMappingsResult; + } + + /** + * Sends `network_id` explicitly: through + * aggkit-proxy, an unqualified `/sync-status` request 400s ("missing + * mandatory query parameter: network_id") because the proxy has no default + * network to route to. Safe against a direct aggkit instance too — + * `GetSyncStatusHandler` never reads `network_id`; it always reports that + * instance's own L1+L2 status regardless of the query. + */ + async getSyncStatus(): Promise { + const query = this.buildQuery({ network_id: this.networkId }); + const { status, text } = await this.requestRaw('/sync-status', query); + this.assertOk('/sync-status', status, text); + + return JSON.parse(text) as AggkitSyncStatus; + } + + /** + * Root health check (`GET {baseUrl}/`, not under `/bridge/v1`). Not part + * of the aggregator's needs — provided as a thin, low-risk extension over + * the same fetch/retry plumbing. + */ + async getHealth(): Promise { + const url = `${this.rootUrl}/`; + const { status, text } = await fetchRawText(url, this.fetchConfig); + + if (status < 200 || status >= 300) { + throw new AggkitApiError({ + message: this.parseErrorMessage(text), + httpStatus: status, + endpoint: '/', + body: text, + }); + } + + return JSON.parse(text) as AggkitHealthResponse; + } + + /** + * Bridge tracker lookup (aggkit `tracker/v1`, + * `GET /tracker/v1/network/{network_id}/tx/{tx_hash}`, docs/bridgetracker/API.md). + * Types were fixture-derived from a v0.11.0-rc4 enclave and live-verified + * unchanged on v0.11.0-rc5 (agglayer/aggkit#1781, fixed in PR #1784 — + * see `types.ts`'s tracker-section module doc for the full wire-format + * writeup). Registers `txHash` in the tracker's supervised list if it was not + * already tracked, and always returns `200 OK` with the current + * `AggkitTrackingData` — `bridge_status`/`step_index`/`all_steps` are + * `null` until the tracker resolves the bridge (or forever, if it gives up + * and sets `error` instead). + * + * `networkId` defaults to `this.networkId` (the network this client + * instance is bound to) but MUST be passed explicitly by callers that + * route L1 (network 0) traffic through an L2-keyed client instance — e.g. + * `AggkitBridgeAggregator.clientForNetworkOrL1` — whose own `this.networkId` + * is not 0. The URL path always uses the `networkId` argument, never the + * instance's own `this.networkId` implicitly. + * + * **Terminal semantics** — stop polling once EITHER is true: + * - `tracking_status === 'finished'` (the bridge reached its last step, + * `Claimed`). + * - `tracking_status === 'error'` AND `bridge_status === null` — the + * tracker gave up ever resolving the bridge at all (tx not found, or not + * a bridge tx). This is distinct from a per-step error inside + * `all_steps[i].error`: those are non-terminal — the tracker retries + * them on its own. Note that a per-step error ALSO reports + * `tracking_status: 'error'` (aggkit derives it from the step at + * `step_index` — `bridgetracker/domain/tracking_data.go`), just with + * `bridge_status` populated — which is exactly why the terminal check + * must include `bridge_status === null`, not `tracking_status` alone. + * + * **Polling guidance**: the tracker has no push/subscription channel, only + * this REST lookup, so callers must poll. ~5s between calls is a good + * default (matches this SDK's own dev-ui consumer) — stop as soon as the + * terminal condition above is met, and keep polling through any + * non-terminal state, including a regression back to `'registered'` with + * `all_steps: null` (see below). + * + * **Server-side registration/eviction**: the FIRST call for a given + * `(networkId, txHash)` pair registers it with the tracker; that initial + * response, and any poll before the tracker resolves the bridge, comes + * back as `tracking_status: 'registered'` with `bridge_status`, + * `step_index`, and `all_steps` all `null` — this is normal, not an error. + * The tracker is stateful with a bounded retention window + * (`RetentionPeriod`, 30m in the kurtosis-cdk devnet config); if a + * tracked-but-not-yet-terminal bridge is evicted, the next poll silently + * re-registers it from scratch (`'registered'`, `all_steps: null` again) + * rather than erroring — callers should treat this the same as the + * original registration, not as a regression to be surfaced to the user. + */ + async getBridgeTracking( + txHash: string, + networkId: number = this.networkId + ): Promise { + const url = `${this.trackerApiUrl}/network/${networkId}/tx/${txHash}`; + const { status, text } = await fetchRawText(url, this.fetchConfig); + + if (status < 200 || status >= 300) { + throw new AggkitApiError({ + message: this.parseTrackerErrorMessage(text), + httpStatus: status, + endpoint: '/tracker/v1/network/{network_id}/tx/{tx_hash}', + body: text, + }); + } + + return JSON.parse(text) as AggkitTrackingData; + } + + private async requestRaw( + endpoint: string, + query: string + ): Promise<{ status: number; text: string }> { + const url = query + ? `${this.bridgeApiUrl}${endpoint}?${query}` + : `${this.bridgeApiUrl}${endpoint}`; + return fetchRawText(url, this.fetchConfig); + } + + private assertOk(endpoint: string, status: number, text: string): void { + if (status >= 200 && status < 300) { + return; + } + throw new AggkitApiError({ + message: this.parseErrorMessage(text), + httpStatus: status, + endpoint, + body: text, + }); + } + + private parseErrorMessage(text: string): string { + try { + const parsed = JSON.parse(text) as AggkitErrorBody; + return typeof parsed.error === 'string' ? parsed.error : text; + } catch { + return text; + } + } + + /** + * The bridge tracker's error body (`ErrorData`) is `{"code", "message"}`, + * NOT the bridge-service's `{"error"}` shape `parseErrorMessage` handles — + * see `AggkitTrackerErrorData`. + */ + private parseTrackerErrorMessage(text: string): string { + try { + const parsed = JSON.parse(text) as AggkitTrackerErrorData; + return typeof parsed.message === 'string' ? parsed.message : text; + } catch { + return text; + } + } + + private assertPageSize(pageSize: number | undefined): void { + if (pageSize !== undefined && pageSize > MAX_PAGE_SIZE) { + throw new RangeError( + `pageSize must be <= ${MAX_PAGE_SIZE} (received ${pageSize})` + ); + } + } + + private assertNetworkIds(networkIds: number[] | undefined): void { + if (networkIds !== undefined && networkIds.length > MAX_NETWORK_IDS) { + throw new RangeError( + `networkIds must contain at most ${MAX_NETWORK_IDS} entries (received ${networkIds.length})` + ); + } + } + + private buildQuery(params: Record): string { + const parts: string[] = []; + + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + continue; + } + parts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(value.join(','))}` + ); + continue; + } + + parts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}` + ); + } + + return parts.join('&'); + } +} diff --git a/src/aggkit/errors.ts b/src/aggkit/errors.ts new file mode 100644 index 0000000..ddeb548 --- /dev/null +++ b/src/aggkit/errors.ts @@ -0,0 +1,40 @@ +/** + * aggkit API Error + * + * Thrown by `AggkitBridgeClient` for every non-2xx HTTP response and for + * network/transport failures after retries are exhausted. Distinct from the + * core `ApiError` (`../core/utils/apiError.ts`) — aggkit's error bodies are a + * uniform bare `{"error": ""}` shape (no `code`/`name`/`details`), + * so this class carries `httpStatus` + `endpoint` + the raw `body` instead. + */ + +export interface AggkitApiErrorArgs { + message: string; + httpStatus: number; + endpoint: string; + body?: string; +} + +export class AggkitApiError extends Error { + public override readonly name: string = 'AggkitApiError'; + /** HTTP status code: 400/404/500/502/503 for server errors (502 = aggkit-proxy backend unreachable). */ + public readonly httpStatus: number; + /** The aggkit endpoint path that was called, e.g. "/bridges". */ + public readonly endpoint: string; + /** Raw response body text (the `{"error": "..."}` payload), when available. */ + public readonly body?: string; + + constructor(args: AggkitApiErrorArgs) { + super(args.message); + + this.httpStatus = args.httpStatus; + this.endpoint = args.endpoint; + if (args.body !== undefined) { + this.body = args.body; + } + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AggkitApiError); + } + } +} diff --git a/src/aggkit/httpRaw.ts b/src/aggkit/httpRaw.ts new file mode 100644 index 0000000..7d42a01 --- /dev/null +++ b/src/aggkit/httpRaw.ts @@ -0,0 +1,82 @@ +/** + * Raw-text HTTP GET helper for aggkit responses. + * + * `core/utils/httpClient.ts`'s `HttpClient` is the SDK's general-purpose + * fetch wrapper, but it calls `response.json()` internally + * (`httpClient.ts:158`) and hands back already-parsed data — which would + * silently corrupt the bare-number `global_index` on `/bridges` responses + * (see `parsing.ts`). `AggkitBridgeClient` needs the raw response TEXT before + * any JSON parsing happens, so this module re-implements `HttpClient`'s + * retry/backoff/timeout policy (same defaults, same exponential-backoff + * algorithm, same retryable-error heuristic) around `fetch`, returning the + * raw body text and HTTP status instead of parsed JSON. This keeps + * `core/utils/httpClient.ts` itself untouched while preserving its retry + * semantics for the aggkit client. + */ + +export interface RawFetchConfig { + timeout: number; + retries: number; + retryDelay: number; +} + +export interface RawFetchResult { + status: number; + text: string; +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes('timeout') || + message.includes('network') || + message.includes('fetch') + ); + } + return false; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function fetchRawText( + url: string, + config: RawFetchConfig +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= config.retries; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + }); + clearTimeout(timeoutId); + + const text = await response.text(); + return { status: response.status, text }; + } catch (error) { + clearTimeout(timeoutId); + + lastError = + error instanceof Error && error.name === 'AbortError' + ? new Error(`Request timeout after ${config.timeout}ms`) + : (error as Error); + + if (attempt === config.retries || !isRetryableError(lastError)) { + break; + } + + await delay(config.retryDelay * Math.pow(2, attempt)); + } + } + + throw new Error( + `Request failed after ${config.retries} retries: ${lastError?.message ?? 'Unknown error'}` + ); +} diff --git a/src/aggkit/index.ts b/src/aggkit/index.ts new file mode 100644 index 0000000..fa2e1c3 --- /dev/null +++ b/src/aggkit/index.ts @@ -0,0 +1,93 @@ +/** + * aggkit Bridge Module + * + * Standalone module for talking to the aggkit `bridge/v1` REST service. + * Kept separate from NATIVE/core so neither is touched by this integration. + * Provides the single-network `AggkitBridgeClient` and the multi-network + * `AggkitBridgeAggregator` (fan-out/join/status-derivation/token-metadata). + * + * ## Multi-Network Proxy Configuration + * + * A single `AggkitBridgeAggregator` instance can proxy all networks through + * one physical backend URL when an AggKit proxy is deployed. + * Create multiple clients with distinct `networkId` values pointed at the same + * base URL: + * + * ```typescript + * const aggregator = new AggkitBridgeAggregator({ + * networks: { + * 1: { baseUrl: "http://proxy.local:8080", networkId: 1 }, + * 2: { baseUrl: "http://proxy.local:8080", networkId: 2 }, // Same URL, different networkId + * }, + * }); + * ``` + * + * The proxy multiplexes networks via `?network_id=` query parameter; the URL + * is the same for all networks. This is the correct configuration for devnets + * with an aggkit-proxy service fronting multiple L2s. + * + * ## Bridge Tracking + * + * `AggkitBridgeClient.getBridgeTracking` / `AggkitBridgeAggregator.getBridgeTracking` + * (S8/S9) poll aggkit's `tracker/v1` REST API for a single bridge transaction's + * step-by-step progress (registers the tx on first call). See the JSDoc on + * those methods for terminal-state/polling guidance, and the + * `AggkitTrackingData` family in `types.ts` for the full wire-format + * reference. That reference was captured off a live devnet and matches + * aggkit's `docs/bridgetracker/API.md` as corrected by v0.11.0-rc5 + * (agglayer/aggkit#1784): most enums ship as bare strings (no numeric + + * `_string` pairs — only `error_type` and certificate `status` keep that + * convention), and steps carry `step_name`, not `step`. rc4's API.md + * described these differently; the wire format itself never changed. + */ + +export { AggkitBridgeClient } from './client'; +export { AggkitBridgeAggregator } from './aggregator'; +export { AggkitApiError } from './errors'; +export type { AggkitApiErrorArgs } from './errors'; + +export type { + AggkitBridgeClientConfig, + AggkitBridgesResult, + AggkitClaimsResult, + AggkitTokenMappingsResult, + AggkitBridge, + AggkitClaim, + AggkitClaimProof, + AggkitL1InfoTreeLeaf, + AggkitTokenMapping, + AggkitSyncStatus, + AggkitSyncStatusInfo, + AggkitHealthResponse, + AggkitErrorBody, + AggkitAggregatorConfig, + AggkitTransactionStatus, + AggkitTransaction, + AggkitFailedNetwork, + AggkitActivityPage, + AggkitPageCursor, + AggkitTokenMetadata, + AggkitTrackingStatus, + AggkitBridgeType, + AggkitBridgeLeafType, + AggkitBridgeStep, + AggkitStepStatus, + AggkitTrackerErrorType, + AggkitTrackerErrorTypeString, + AggkitCertificateStatus, + AggkitCertificateStatusString, + AggkitBridgeStatusEvent, + AggkitBridgeStatus, + AggkitTrackerErrorStep, + AggkitCertificateData, + AggkitWaitingGERUpdateResult, + AggkitWaitingLERUpdateResult, + AggkitPendingInclusionResult, + AggkitWaitL1SettledGERResult, + AggkitWaitingGERInjectionResult, + AggkitWaitingClaimResult, + AggkitBridgeStepResult, + AggkitBridgeStepPath, + AggkitTrackingData, + AggkitTrackerErrorData, +} from './types'; diff --git a/src/aggkit/parsing.ts b/src/aggkit/parsing.ts new file mode 100644 index 0000000..571f73a --- /dev/null +++ b/src/aggkit/parsing.ts @@ -0,0 +1,16 @@ +/** + * BigInt-safe parsing helpers for aggkit responses. + * + * `global_index` is a bare JSON number on `/bridges` (and would exceed + * `Number.MAX_SAFE_INTEGER` for L1-origin deposits, e.g. + * `18446744073709551621`). Default `JSON.parse` silently corrupts such + * values into an imprecise IEEE-754 double. The fix is + * to quote the bare integer with a regex BEFORE calling `JSON.parse`, so + * `global_index` always parses as a `string`. + * + * This is idempotent for `/claims`, where `global_index` is already a JSON + * string — the regex only matches an unquoted run of digits. + */ +export function quoteGlobalIndex(raw: string): string { + return raw.replace(/"global_index":\s*(-?\d+)/g, '"global_index":"$1"'); +} diff --git a/src/aggkit/types.ts b/src/aggkit/types.ts new file mode 100644 index 0000000..0d0b4fa --- /dev/null +++ b/src/aggkit/types.ts @@ -0,0 +1,618 @@ +/** + * aggkit Bridge REST API — types + * + * Canonical TypeScript shapes for the aggkit `bridge/v1` REST surface, derived + * from live fixtures (`__fixtures__/`) + aggkit `types.go`. + * + * IMPORTANT: `global_index` is a bare JSON number on `/bridges` (exceeds + * Number.MAX_SAFE_INTEGER) but a JSON string on `/claims`. Both are carried + * here as `string` — see `AggkitBridgeClient` (`../aggkit/client.ts`) for the + * BigInt-safe parsing that guarantees this invariant. + */ + +/** Config for a single-network aggkit bridge-service client. */ +export interface AggkitBridgeClientConfig { + /** e.g. "http://127.0.0.1:33460" (WITHOUT `/bridge/v1`; the client appends it). */ + baseUrl: string; + /** The L2 network id this aggkit instance serves. */ + networkId: number; + /** Request timeout in ms. Default 30000 (matches `HttpClient`'s default). */ + timeout?: number; + /** Max retry attempts on retryable (network/timeout) errors. Default 3. */ + retries?: number; + /** Base retry backoff delay in ms (exponential). Default 1000. */ + retryDelay?: number; +} + +// ---- BARE list envelopes ---- + +export interface AggkitBridgesResult { + bridges: AggkitBridge[]; + count: number; +} + +export interface AggkitClaimsResult { + claims: AggkitClaim[]; + count: number; +} + +export interface AggkitTokenMappingsResult { + token_mappings: AggkitTokenMapping[]; + count: number; +} + +// ---- bridge row (global_index parsed from a bare JSON number into a string) ---- + +export interface AggkitBridge { + block_num: number; + block_pos: number; + /** May be "" or absent; do not trust for identity beyond sender display. */ + from_address: string; + tx_hash: string; + /** Parsed from a bare JSON number, quoted before JSON.parse — see client.ts. */ + global_index: string; + /** Unix seconds. */ + block_timestamp: number; + /** 0 = asset, 1 = message. */ + leaf_type: number; + origin_network: number; + /** Origin TOKEN address (0x0 = native). */ + origin_address: string; + destination_network: number; + destination_address: string; + /** Decimal wei. */ + amount: string; + /** "0x" for native. */ + metadata: string; + /** Local leaf index in the origin tree. */ + deposit_count: number; + /** Unique id. */ + bridge_hash: string; + txn_sender: string; + to_address: string; +} + +// ---- claim row (global_index is already a JSON string) ---- + +export interface AggkitClaim { + block_num: number; + block_timestamp: number; + tx_hash: string; + global_index: string; + origin_address: string; + origin_network: number; + destination_address: string; + destination_network: number; + amount: string; + /** ALWAYS "" in the captured dataset — never use for identity. */ + from_address: string; + mainnet_exit_root: string; + rollup_exit_root: string; + global_exit_root: string; + metadata: string; + is_message: boolean; + /** Only present with `includeAllFields: true`. */ + proof_local_exit_root?: string[]; + /** Only present with `includeAllFields: true`. */ + proof_rollup_exit_root?: string[]; +} + +// ---- claim proof ---- + +export interface AggkitL1InfoTreeLeaf { + block_num: number; + block_pos: number; + l1_info_tree_index: number; + previous_block_hash: string; + timestamp: number; + mainnet_exit_root: string; + rollup_exit_root: string; + global_exit_root: string; + hash: string; +} + +export interface AggkitClaimProof { + /** 32 hashes. */ + proof_local_exit_root: string[]; + /** 32 hashes (all-zero in a single-L2 setup). */ + proof_rollup_exit_root: string[]; + l1_info_tree_leaf: AggkitL1InfoTreeLeaf; +} + +// ---- token mapping ---- + +export interface AggkitTokenMapping { + block_num: number; + block_pos: number; + block_timestamp: number; + tx_hash: string; + origin_network: number; + origin_token_address: string; + wrapped_token_address: string; + metadata: string; + /** 0 = wrapped, 1 = sovereign. */ + token_type: number; + is_not_mintable: boolean; +} + +// ---- sync status ---- + +export interface AggkitSyncStatusInfo { + contract_deposit_count: number; + synchronized_deposit_count: number; + is_synced: boolean; + is_active: boolean; +} + +export interface AggkitSyncStatus { + l1_info: AggkitSyncStatusInfo; + l2_info: AggkitSyncStatusInfo; +} + +// ---- root health check ---- +// Not part of the `/bridge/v1` surface (served at the instance root), but +// captured as a fixture and requested as an S4 deliverable type. + +export interface AggkitHealthResponse { + status: string; + time: string; + version: string; +} + +// ---- error body (every non-2xx response) ---- + +export interface AggkitErrorBody { + error: string; +} + +/** + * ---- Aggregator (S5): multi-network fan-out + status derivation ---- + * + * See `aggregator.ts` for the fan-out/join/status-derivation/ + * token-metadata implementation these types support. + */ + +/** Config for the multi-network aggregator: one aggkit base URL per L2 networkId. */ +export interface AggkitAggregatorConfig { + /** Map of L2 networkId -> aggkit REST base URL (no `/bridge/v1` suffix). */ + networks: Record; + timeout?: number; + retries?: number; + retryDelay?: number; +} + +/** + * UI transaction status (`app/types/transaction.ts` `TransactionStatus`). + * + * State machine: + * - **BRIDGED**: Deposit emitted on source, not yet included in L1 info tree. + * - **LEAF_INCLUDED**: Deposit included in L1 info tree on source; for L2 destinations + * only: the source leaf exists but the destination's GER injection lags (common in + * fresh enclaves where L2 block height exceeds L1). Destination's injected leaf will + * eventually catch up. For L1 destinations, this state never occurs (L1 has no "injection" + * concept — leaf inclusion suffices). + * - **READY_TO_CLAIM**: Claim proof available; user can call `claimAsset` on destination. + * For L1 destinations: source leaf included. For L2 destinations: both source included + * AND destination injected. + * - **CLAIMED**: Claim completed on destination; balance received. + */ +export type AggkitTransactionStatus = + 'BRIDGED' | 'LEAF_INCLUDED' | 'READY_TO_CLAIM' | 'CLAIMED'; + +/** + * UI-shaped transaction row (mirrors `app/types/transaction.ts` `Transaction` + * field-for-field). Produced by `AggkitBridgeAggregator` + * from a joined + status-derived `AggkitBridge` row. + */ +export interface AggkitTransaction { + hubUID: string; + txSender: string; + fromAddress: string; + receiverAddress: string; + sourceNetwork: number; + destinationNetwork: number; + amount: string; + status: AggkitTransactionStatus; + lastUpdatedAt: number; + bridgeHash: string; + metadata: string; + leafType: string; + depositCount: number; + transactionIndex: number; + transactionHash: string; + claimTransactionHash?: string; + claimTimestamp?: number; + claimBlockNumber?: number; + blockNumber: number; + globalIndex: string; + originTokenAddress: string; + originTokenNetwork: number; + timestamp: number; + /** For `Bridge.isClaimed` — equals `deposit_count`, NOT the L1-info-tree index. */ + leafIndex: number; + /** The L1-info-tree index (for `/claim-proof`'s `leaf_index`); only set once probed (Tier 2). */ + leafIndexForProof?: number; +} + +/** One configured network's fan-out failed; its rows are simply absent from the page. */ +export interface AggkitFailedNetwork { + networkId: number; + error: string; + httpStatus?: number; +} + +/** Result of `AggkitBridgeAggregator.getActivity`. */ +export interface AggkitActivityPage { + data: AggkitTransaction[]; + pagination: { + total: number; + limit: number; + nextStartAfterCursor?: string; + }; + failedNetworks: AggkitFailedNetwork[]; +} + +/** + * Opaque composite cursor: one 1-based page counter per fan-out call. + * A stored value of `0` is a sentinel meaning "this call + * is exhausted — do not refetch it" (see aggregator.ts). + */ +export type AggkitPageCursor = Record; + +/** + * Token metadata output shape = UI's existing `TokenMetadata` + * (`app/services/tokenMetadata.ts`), unchanged so the UI consumer contract + * does not need to change. + */ +export interface AggkitTokenMetadata { + name: string; + symbol: string; + decimals: number; + tokenAddress: string; + network?: number | string; + totalSupply?: string; + logoURI?: string; + originTokenAddress?: string; + originTokenNetwork?: number | string; + wrappedTokenAddressV1?: string; + wrappedTokenAddressV2?: string; +} + +/** + * ---- Bridge Tracker (S4/S5): aggkit `tracker/v1` REST API ---- + * + * Canonical TypeScript shapes for `GET /tracker/v1/network/{network_id}/tx/{tx_hash}` + * (and its `health` sibling). Originally derived from LIVE fixtures captured + * off a real v0.11.0-rc4 devnet enclave on 2026-08-07, not from rc4's + * `docs/bridgetracker/API.md` — at the time, the docs disagreed with the + * live wire format in several places (agglayer/aggkit#1781). That gap is + * now closed: v0.11.0-rc5 (agglayer/aggkit#1784) rewrote + * `docs/bridgetracker/API.md` to match the wire format exactly; the + * serializer itself never changed between rc4 and rc5 (PR #1784: "doc-only + * for the tracker wire format; the serializer is unchanged"). Shapes below + * were re-verified byte-identical on a live rc5 enclave on 2026-08-10 — no + * type-shape changes were needed. The notes below describe the actual wire + * format, which now matches current upstream docs: + * + * - `tracking_status`, `bridge_type`, and `status` (the step's) ship as + * BARE STRINGS on the wire — no numeric value, no `_string` + * companion. + * - The step's enum field is not named `step` — it's `step_name`, also a + * bare string. Each step entry additionally carries a `step_index` + * integer (redundant with the entry's position in `all_steps`). + * - `bridge_leaf_type` isn't a sibling of `bridge_type` on `BridgeStatus`; + * it (also a bare string) lives one level down, nested inside an `event` + * object alongside the origin/destination/amount fields. + * - Per-step `start_date`/`end_date`/`result`/`error` are OMITTED keys when + * not yet applicable (e.g. a `pending` step has none of them), not `null` + * — confirmed directly from raw fixture JSON, never a `"start_date": null` + * anywhere. `expected_duration` has never been observed on the wire, in + * any step, at any status — kept as an optional field since rc5's docs + * flag it as reserved (wired via `omitempty` but not currently populated + * by any resolver), still unconfirmed empirically. + * - `error_type` (`ErrorStep`) and `status` (`CertificateData`) are the + * ONLY two fields that keep the numeric + `_string` companion convention + * — rc5's docs call this out explicitly ("no general rule: check the + * field's type in the tables below"); every other enum field above is a + * bare string. + * - `CertificateData.settlement_tx_hash` is an OMITTED key (not `null`) + * until a certificate leaves `Pending`, confirmed by fixtures. + * `CertificateData.previous_ler`'s documented "`null` for a network's + * first certificate" case was never exercised by any fixture (every + * capture already had prior certificates) — kept as `string | null` per + * the docs, unconfirmed either way. + * + * Top-level `TrackingData` fields that are documented as nullable + * (`bridge_status`/`step_index`/`all_steps`/`error`) DO serialize as + * explicit JSON `null`, confirmed by fixtures (e.g. `"error": null` appears + * verbatim throughout). Dates (`start_date`/`end_date`) stay ISO strings as + * received (never constructed into `Date` in the SDK). + */ + +// ---- enums ---- + +/** + * `TrackingData.tracking_status`: bare string on the wire (fixture-confirmed, + * matches aggkit's rc5-corrected API.md) — no numeric value, no + * `tracking_status_string` companion. + */ +export type AggkitTrackingStatus = + 'registered' | 'running' | 'error' | 'finished'; + +/** + * `BridgeStatus.bridge_type`: bare string on the wire (fixture-confirmed, + * matches aggkit's rc5-corrected API.md) — no numeric value, no + * `bridge_type_string` companion. + */ +export type AggkitBridgeType = 'L1->L2' | 'L2->L1' | 'L2->L2'; + +/** + * `BridgeStatus.event.leaf_type`: bare string on the wire (fixture-confirmed, + * same bare-string convention as `bridge_type`) — lives nested under + * `event`, matching aggkit's rc5-corrected API.md (not a `BridgeStatus`- + * level `bridge_leaf_type`/`bridge_leaf_type_string` pair). Only `'Asset'` + * was directly observed (all captured bridges were `bridgeAsset`); + * `'Message'` is the documented sibling value. + */ +export type AggkitBridgeLeafType = 'Asset' | 'Message'; + +/** + * `BridgeStepPath.step_name`: bare string on the wire (fixture-confirmed, + * matches aggkit's rc5-corrected API.md) — the field isn't named `step`, + * and there is no numeric value or `step_string` companion. + */ +export type AggkitBridgeStep = + | 'WaitingGERUpdate' + | 'WaitingLERUpdate' + | 'PendingInclusion' + | 'CertificatePending' + | 'WaitL1SettledGER' + | 'WaitingGERInjection' + | 'WaitingClaim' + | 'Claimed'; + +/** + * `BridgeStepPath.status`: bare string on the wire (fixture-confirmed, + * matches aggkit's rc5-corrected API.md) — no numeric value, no + * `status_string` companion. + */ +export type AggkitStepStatus = 'pending' | 'inProgress' | 'done' | 'error'; + +/** + * `ErrorStep.error_type`: 0->transient, 1->permanent, 2->exhausted (retries + * have been given up on). Fixture-confirmed to match API.md's documented + * numeric + `_string` companion convention exactly. + */ +export type AggkitTrackerErrorType = 0 | 1 | 2; +/** `ErrorStep.error_type_string`. */ +export type AggkitTrackerErrorTypeString = + 'transient' | 'permanent' | 'exhausted'; + +/** + * `CertificateData.status`: mapped from the agglayer proto (aggkit + * `agglayer_grpc_client.go`): 0->Pending, 1->Proven, 2->Candidate, + * 3->InError, 4->Settled. Fixture-confirmed to match API.md's documented + * numeric + `_string` companion convention exactly. + */ +export type AggkitCertificateStatus = 0 | 1 | 2 | 3 | 4; +/** `CertificateData.status_string`. */ +export type AggkitCertificateStatusString = + 'Pending' | 'Proven' | 'Candidate' | 'InError' | 'Settled'; + +// ---- shared structures ---- + +/** + * `BridgeStatus.event`: the underlying `BridgeEvent` (bridgeAsset/ + * bridgeMessage) log that seeded this bridge. Nested under `bridge_status. + * event` on the wire, matching aggkit's rc5-corrected API.md (rc4's docs + * did not document this nesting, or these fields as part of `BridgeStatus` + * at all); confirmed by every lifecycle fixture. + */ +export interface AggkitBridgeStatusEvent { + leaf_type: AggkitBridgeLeafType; + origin_network: number; + /** Origin TOKEN address (0x0 = native). */ + origin_address: string; + destination_network: number; + destination_address: string; + /** Decimal wei. */ + amount: string; + /** Local leaf index in the origin tree. */ + deposit_count: number; +} + +/** + * `TrackingData.bridge_status`: `null` while `tracking_status` is + * `registered`, and forever `null` if the tracker gives up resolving the + * bridge (`AggkitTrackingData.error` is set instead). + */ +export interface AggkitBridgeStatus { + bridge_type: AggkitBridgeType; + /** Block, on the origin network, where the `BridgeEvent` was emitted. */ + block_number: number; + /** Position of the `BridgeEvent` log within `block_number`. */ + log_index: number; + /** Unix seconds; the origin block's timestamp. */ + block_timestamp: number; + event: AggkitBridgeStatusEvent; +} + +/** + * Carried both in `AggkitBridgeStepPath.error` (that step of an otherwise- + * resolved bridge failed) and in `AggkitTrackingData.error` (the tracker is + * failing to resolve the bridge — tx not found, or the tx exists but emitted + * no `BridgeEvent`). In the latter case `retry_count` counts the not-found + * polls so far: while `error_type` is `transient` (0) the tracker is still + * retrying and `tracking_status` stays `'registered'` (fixture-confirmed — + * `tracker_registered.json` carries a transient error at `retry_count: 1`); + * once retries are exhausted (`error_type` 2) `tracking_status` becomes + * `'error'` and the field is final. + */ +export interface AggkitTrackerErrorStep { + error_type: AggkitTrackerErrorType; + error_type_string: AggkitTrackerErrorTypeString; + retry_count: number; + /** Human-readable description(s), one entry per occurrence. */ + description: string[]; +} + +/** + * The agglayer certificate's current data; carried by the + * `CertificatePending` step's `result` (set as soon as a certificate exists, + * updated as its status changes, and reflects the final settled data once + * `status` is `Settled`). + */ +export interface AggkitCertificateData { + certificate_id: string; + status: AggkitCertificateStatus; + status_string: AggkitCertificateStatusString; + /** Only set if the proto carries `Error.Message` (relevant for `InError` certs). */ + error?: string; + /** + * Omitted (not `null`) while `status` is still `Pending` — fixture- + * confirmed (`lifecycle_l2l1`'s first `CertificatePending` snapshot has no + * `settlement_tx_hash` key at all). Present from `Candidate` onward. + */ + settlement_tx_hash?: string; +} + +// ---- per-step `BridgeStepPath.result` shapes (StepResult, keyed by `step`) ---- + +/** `WaitingGERUpdate` step result: GER resulting from the L1 update, and where it landed. */ +export interface AggkitWaitingGERUpdateResult { + l1_info_tree_index: number; + ger: string; + mer: string; + rer: string; + block_number: number; + block_timestamp: number; + log_index: number; +} + +/** `WaitingLERUpdate` step result: LER resulting from the origin L2 update. */ +export interface AggkitWaitingLERUpdateResult { + network_id: number; + ler: string; + block_number: number; +} + +/** `PendingInclusion` step result: the certificate that first includes the bridge. */ +export interface AggkitPendingInclusionResult { + certificate_id: string; + new_ler: string; + /** `null` for a network's first certificate. */ + previous_ler: string | null; +} + +/** + * `WaitL1SettledGER` step result: evidence, read off the settlement tx + * receipt once it reaches L1 finality, that the settlement propagated to the + * L1 Global Exit Root. + */ +export interface AggkitWaitL1SettledGERResult { + tx_hash: string; + block_number: number; + ger: string; + /** Never `null` once the step is `done`; see aggkit `API.md` for the resolution rules. */ + l1_info_tree_index: number | null; + has_verify_batches_trusted_aggregator: boolean; + has_update_l1_info_tree: boolean; + /** Informational only — unlike the other two `has_*` fields, not required for the step to complete. */ + has_update_l1_info_tree_v2: boolean; +} + +/** `WaitingGERInjection` step result: GER injected on the destination network covering the bridge. */ +export interface AggkitWaitingGERInjectionResult { + ger: string; +} + +/** `WaitingClaim` step result: the claim transaction on the destination network. */ +export interface AggkitWaitingClaimResult { + claim_tx: string; + block_number: number; +} + +/** + * `BridgeStepPath.result`: shape depends on that entry's `step`. + * `CertificatePending`'s result is the full `AggkitCertificateData`; steps + * not covered by aggkit's `StepResult` table (i.e. `Claimed`) never carry a + * result. + */ +export type AggkitBridgeStepResult = + | AggkitWaitingGERUpdateResult + | AggkitWaitingLERUpdateResult + | AggkitPendingInclusionResult + | AggkitCertificateData + | AggkitWaitL1SettledGERResult + | AggkitWaitingGERInjectionResult + | AggkitWaitingClaimResult; + +/** + * One milestone of a bridge's expected route (`AggkitTrackingData.all_steps[i]`). + * `start_date`/`end_date`/`result`/`error` are OMITTED keys (not `null`) + * until applicable — fixture-confirmed: a `pending` step has none of these + * keys, an `inProgress` step has only `start_date`, a `done` step has both + * dates plus `result` (when that step produces one). `expected_duration` + * was never observed on the wire in any fixture at any status. + */ +export interface AggkitBridgeStepPath { + /** Position of this entry within `all_steps` (redundant with array index; now documented by aggkit's rc5-corrected API.md — rc4's did not cover it). */ + step_index: number; + step_name: AggkitBridgeStep; + status: AggkitStepStatus; + start_date?: string; + end_date?: string; + /** Human-readable duration string (e.g. "5m0s"); never constructed into a `Date` here. Undocumented on the wire — never observed in any captured fixture. */ + expected_duration?: string; + /** Present only once the step produces a result; absent for steps without one (e.g. `Claimed`). */ + result?: AggkitBridgeStepResult; + /** Present only when `status` is `error`; see `AggkitTrackerErrorStep`. Never observed in captured fixtures. */ + error?: AggkitTrackerErrorStep; +} + +/** + * Body of every bridge-tracker REST response (always `200 OK`) and of every + * WebSocket `status` message (WebSocket itself is a non-goal for this SDK + * method). Calling `GET /tracker/v1/network/{network_id}/tx/{tx_hash}` + * registers `tx_hash` in the tracker's supervised list if it was not already + * tracked. + */ +export interface AggkitTrackingData { + tracking_status: AggkitTrackingStatus; + network_id: number; + tx_hash: string; + /** `null` under the same conditions as `step_index`/`all_steps` — see `error`. */ + bridge_status: AggkitBridgeStatus | null; + /** + * Index into `all_steps` of the step that explains `tracking_status`: the + * step in progress when `running`, the step in error when `error`, or the + * last step (`Claimed`) when `finished`. + */ + step_index: number | null; + /** All expected steps of the bridge's route; `null` until the tracker resolves it. */ + all_steps: AggkitBridgeStepPath[] | null; + /** + * Set while the tracker is failing to resolve the bridge (e.g. the tx does + * not exist on the network, or is not a bridge transaction): `transient` + * (0) while it is still retrying (`tracking_status` stays `'registered'` — + * fixture-confirmed by `tracker_registered.json`), `exhausted` (2) once it + * has given up for good (`tracking_status: 'error'`, `bridge_status` + * forever `null`). Unrelated to per-step errors, which live in + * `all_steps[i].error` instead. + */ + error: AggkitTrackerErrorStep | null; +} + +/** + * Bridge-tracker `400` error body (`ErrorData`) — a DIFFERENT shape from the + * bridge-service `AggkitErrorBody` (`{"error": "..."}`): the tracker uses + * `{"code": ..., "message": "..."}` instead. Reserved for invalid path + * parameters (`network_id`/`tx_hash`), before any bridge is registered; once + * a bridge is registered, every outcome (including the tracker giving up on + * it) is reported through `AggkitTrackingData.error` instead. + */ +export interface AggkitTrackerErrorData { + /** HTTP-like error code: always 400 (invalid params). */ + code: number; + message: string; +} diff --git a/src/index.ts b/src/index.ts index 596915c..d410abd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,61 @@ export { TransactionStatus } from './types'; // Re-export error classes export { ApiError } from './core/utils/apiError'; +// Re-export the standalone aggkit bridge-service module: S4's single-network +// `AggkitBridgeClient`, plus S5's multi-network `AggkitBridgeAggregator` +// (fan-out/join/status-derivation/token-metadata; see src/aggkit/index.ts). +export { + AggkitBridgeClient, + AggkitBridgeAggregator, + AggkitApiError, +} from './aggkit'; +export type { + AggkitApiErrorArgs, + AggkitBridgeClientConfig, + AggkitBridgesResult, + AggkitClaimsResult, + AggkitTokenMappingsResult, + AggkitBridge, + AggkitClaim, + AggkitClaimProof, + AggkitL1InfoTreeLeaf, + AggkitTokenMapping, + AggkitSyncStatus, + AggkitSyncStatusInfo, + AggkitHealthResponse, + AggkitErrorBody, + AggkitAggregatorConfig, + AggkitTransactionStatus, + AggkitTransaction, + AggkitFailedNetwork, + AggkitActivityPage, + AggkitPageCursor, + AggkitTokenMetadata, + AggkitTrackingStatus, + AggkitBridgeType, + AggkitBridgeLeafType, + AggkitBridgeStep, + AggkitStepStatus, + AggkitTrackerErrorType, + AggkitTrackerErrorTypeString, + AggkitCertificateStatus, + AggkitCertificateStatusString, + AggkitBridgeStatusEvent, + AggkitBridgeStatus, + AggkitTrackerErrorStep, + AggkitCertificateData, + AggkitWaitingGERUpdateResult, + AggkitWaitingLERUpdateResult, + AggkitPendingInclusionResult, + AggkitWaitL1SettledGERResult, + AggkitWaitingGERInjectionResult, + AggkitWaitingClaimResult, + AggkitBridgeStepResult, + AggkitBridgeStepPath, + AggkitTrackingData, + AggkitTrackerErrorData, +} from './aggkit'; + const defaultConfig: SDKConfig = { mode: [SDK_MODES.CORE], }; diff --git a/src/native/bridge/util.ts b/src/native/bridge/util.ts index c3f9359..913ce3f 100644 --- a/src/native/bridge/util.ts +++ b/src/native/bridge/util.ts @@ -195,7 +195,12 @@ export class BridgeUtil { } /** - * Fetch merkle proof from Polygon's hub API + * Fetch merkle proof from Polygon's hub API. + * + * @deprecated Legacy Bridge Hub API path. The aggkit bridge-service migration + * (see @agglayer/sdk `src/aggkit/*` — `AggkitBridgeAggregator.getClaimInputs`) + * supersedes this proof source. Left in place for the existing NATIVE claim + * flow; remove once all consumers move to the aggkit claim-inputs path. */ private async fetchMerkleProof( networkId: number, diff --git a/src/native/chains/__tests__/registry.test.ts b/src/native/chains/__tests__/registry.test.ts new file mode 100644 index 0000000..9899546 --- /dev/null +++ b/src/native/chains/__tests__/registry.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { ChainRegistry, chainRegistry } from '../registry'; + +/** + * ChainRegistry networkId-collision precedence. + * + * Background: the SDK pre-seeds default chains (Ethereum mainnet at + * networkId 0, Katana at networkId 20, Sepolia at networkId 0) at + * construction time. A consumer registering their own chain at a colliding + * networkId (e.g. a devnet L1 also at networkId 0, per + * `agglayer-dev-ui/app/context/aggLayerSdk.tsx`) must have that + * registration win over the built-in default — regardless of registration + * order — so `getChainByNetworkId()` (and everything that depends on it: + * `BridgeUtil.fromNetworkId`, `AggkitBridgeAggregator.getTokenMetadata`) + * never silently falls back to a default chain's RPC/nativeCurrency. + * + * Each test below constructs a *fresh* `ChainRegistry` instance (bypassing + * the private constructor) instead of mutating the shared `chainRegistry` + * singleton, so these tests are self-contained and order-independent. + */ +function freshRegistry(): ChainRegistry { + // `ChainRegistry`'s constructor is intentionally private (consumers use + // the exported `chainRegistry` singleton) — bypass it here purely for + // test isolation, so registering a colliding devnet chain in one test + // can't leak into another. + return new (ChainRegistry as unknown as new () => ChainRegistry)(); +} + +const DEVNET_L1 = { + chainId: 1337, + networkId: 0, + name: 'Devnet L1', + rpcUrl: 'http://localhost:8545', + nativeCurrency: { name: 'Devnet Ether', symbol: 'dETH', decimals: 18 }, +}; + +describe('ChainRegistry networkId collision precedence', () => { + it('a consumer-registered chain at networkId 0 wins over the pre-seeded Ethereum mainnet default', () => { + const registry = freshRegistry(); + + registry.registerChain(DEVNET_L1); + + const resolved = registry.getChainByNetworkId(0); + + expect(resolved.chainId).toBe(DEVNET_L1.chainId); + expect(resolved.name).toBe('Devnet L1'); + expect(resolved.rpcUrl).toBe('http://localhost:8545'); + expect(resolved.rpcUrl).not.toBe('https://eth.llamarpc.com'); + }); + + it('precedence holds regardless of registration order (registering the override "late" still wins)', () => { + const registry = freshRegistry(); + + // Register some unrelated chains first, to rule out "last registered + // wins" being the actual mechanism (it must specifically be + // "consumer beats default", not just insertion order). + registry.registerChain({ + ...DEVNET_L1, + chainId: 9999, + networkId: 55, + name: 'Unrelated Chain', + }); + registry.registerChain(DEVNET_L1); + + expect(registry.getChainByNetworkId(0).chainId).toBe(DEVNET_L1.chainId); + }); + + it('is order-independent the other way too: default re-seeded conceptually first still loses to a consumer override registered afterward', () => { + const registry = freshRegistry(); + + // Defaults are always seeded first (constructor). Registering the + // consumer override afterward (the only real-world order) must still + // resolve to the consumer's chain, not the default. + expect(registry.getChainByNetworkId(0).name).toBe('Ethereum'); + registry.registerChain(DEVNET_L1); + expect(registry.getChainByNetworkId(0).name).toBe('Devnet L1'); + }); + + it('regression: with no override registered, default chains resolve exactly as before', () => { + const registry = freshRegistry(); + + const mainnet = registry.getChainByNetworkId(0); + expect(mainnet.chainId).toBe(1); + expect(mainnet.name).toBe('Ethereum'); + expect(mainnet.rpcUrl).toBe('https://eth.llamarpc.com'); + + const katana = registry.getChainByNetworkId(20); + expect(katana.chainId).toBe(747474); + expect(katana.name).toBe('Katana'); + + expect(registry.getChain(1).name).toBe('Ethereum'); + expect(registry.getChain(747474).name).toBe('Katana'); + expect(registry.getChain(11155111).name).toBe('Ethereum Sepolia'); + expect(registry.getSupportedChainIds().sort((a, b) => a - b)).toEqual( + [1, 747474, 11155111].sort((a, b) => a - b) + ); + expect(registry.isChainSupportedByNetworkId(0)).toBe(true); + }); + + it('throws with the original "not found" message when neither an override nor a default share the networkId', () => { + const registry = freshRegistry(); + + expect(() => registry.getChainByNetworkId(999)).toThrow( + /Chain with network ID 999 not found/ + ); + }); + + it('the exported singleton is unaffected by fresh test instances (sanity: singleton still resolves mainnet by default)', () => { + expect(chainRegistry.getChainByNetworkId(0).chainId).toBe(1); + }); +}); diff --git a/src/native/chains/registry.ts b/src/native/chains/registry.ts index 8e5fb3d..76ef99b 100644 --- a/src/native/chains/registry.ts +++ b/src/native/chains/registry.ts @@ -12,6 +12,14 @@ export class ChainRegistry { private static instance: ChainRegistry; private chains: Map = new Map(); private viemChains: Map = new Map(); + // chainIds seeded by `initializeDefaultChains()` at construction time. + // Fixed once, at construction — never mutated afterwards, even if a + // consumer later re-registers one of these chainIds (see `registerChain` + // precedence note below). Used by `getChainByNetworkId` to make + // consumer-registered chains win over built-in defaults on networkId + // collisions (e.g. a devnet L1 registered at networkId 0 vs. the + // pre-seeded Ethereum mainnet default, also at networkId 0). + private readonly defaultChainIds = new Set(); private constructor() { this.initializeDefaultChains(); @@ -27,7 +35,7 @@ export class ChainRegistry { // DEV: if adding new default chains, also update README.md private initializeDefaultChains() { // Ethereum Mainnet - this.registerChain({ + this.registerDefaultChain({ chainId: 1, networkId: 0, name: 'Ethereum', @@ -40,7 +48,7 @@ export class ChainRegistry { }); // Katana - this.registerChain({ + this.registerDefaultChain({ chainId: 747474, networkId: 20, name: 'Katana', @@ -56,7 +64,7 @@ export class ChainRegistry { }); // Ethereum Sepolia Testnet - this.registerChain({ + this.registerDefaultChain({ chainId: 11155111, networkId: 0, name: 'Ethereum Sepolia', @@ -75,7 +83,14 @@ export class ChainRegistry { } /** - * Register a new chain + * Register a new chain. + * + * Precedence note: chains registered here (by a consumer, at any point + * after construction) always take precedence over the SDK's built-in + * defaults (registered via `initializeDefaultChains()`/ + * `registerDefaultChain()`) when `getChainByNetworkId()` resolves a + * networkId collision — regardless of registration order. See + * `getChainByNetworkId()`. */ registerChain(config: ChainConfig): void { this.chains.set(config.chainId, config); @@ -93,6 +108,17 @@ export class ChainRegistry { this.viemChains.set(config.chainId, viemChain); } + /** + * Register a built-in default chain (used only by + * `initializeDefaultChains()`). Identical to `registerChain()`, plus + * marking the chainId as a default for `getChainByNetworkId()` + * precedence purposes. + */ + private registerDefaultChain(config: ChainConfig): void { + this.registerChain(config); + this.defaultChainIds.add(config.chainId); + } + /** * Get chain configuration by ID */ @@ -107,12 +133,24 @@ export class ChainRegistry { } /** - * Get chain configuration by network ID + * Get chain configuration by network ID. + * + * Precedence: multiple registered chains can share a `networkId` (e.g. a + * consumer-registered devnet L1 and the SDK's pre-seeded Ethereum mainnet + * default both at networkId 0, keyed by distinct chainIds). When that + * happens, a consumer-registered chain always wins over a built-in + * default, independent of registration order — a default is only + * returned when no consumer-registered chain shares the networkId. + * Ties among multiple consumer-registered (or multiple default) chains + * fall back to first-registered, matching prior behavior. */ getChainByNetworkId(networkId: number): ChainConfig { - const chain = Array.from(this.chains.values()).find( + const matches = Array.from(this.chains.values()).filter( (chain) => chain.networkId === networkId ); + const chain = + matches.find((chain) => !this.defaultChainIds.has(chain.chainId)) ?? + matches[0]; if (!chain) { throw new Error( `Chain with network ID ${networkId} not found. Available network IDs: ${Array.from( diff --git a/src/native/tokens/erc20.ts b/src/native/tokens/erc20.ts index 4fce97e..6181451 100644 --- a/src/native/tokens/erc20.ts +++ b/src/native/tokens/erc20.ts @@ -67,6 +67,63 @@ export class ERC20 extends BaseContract { } } + /** + * Get on-chain token metadata (name, symbol, decimals, best-effort totalSupply). + * + * Used by `AggkitBridgeAggregator.getTokenMetadata` to compose the ERC20 + * branch of token metadata (aggkit's `/token-mappings` has no + * name/symbol/decimals fields). `totalSupply` reads + * tolerate reverts (some tokens omit it) and are simply left undefined. + */ + async getMetadata(): Promise<{ + name: string; + symbol: string; + decimals: number; + totalSupply?: string; + }> { + const [name, symbol, decimals] = await Promise.all([ + this.client.readContract({ + address: this.tokenAddress as Address, + abi: getAbi('ERC20'), + functionName: 'name', + }), + this.client.readContract({ + address: this.tokenAddress as Address, + abi: getAbi('ERC20'), + functionName: 'symbol', + }), + this.client.readContract({ + address: this.tokenAddress as Address, + abi: getAbi('ERC20'), + functionName: 'decimals', + }), + ]); + + const metadata: { + name: string; + symbol: string; + decimals: number; + totalSupply?: string; + } = { + name: name as string, + symbol: symbol as string, + decimals: Number(decimals), + }; + + try { + const totalSupply = await this.client.readContract({ + address: this.tokenAddress as Address, + abi: getAbi('ERC20'), + functionName: 'totalSupply', + }); + metadata.totalSupply = (totalSupply as bigint).toString(); + } catch { + // totalSupply is optional; tolerate reverts. + } + + return metadata; + } + /** * Build approve transaction */