From c596a287d67dc19f282c2533c5f1d45ce47d7c82 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:58:49 +0200 Subject: [PATCH 01/16] fix(e2e/op-pp): anchor L2 genesis timestamp to L1 head instead of wall-clock op-pp's L1 image (arnaubennassar/geth:op-pp) has its chain data baked in at build time and never advances past that snapshot. The op-geth entrypoint was patching the L2 genesis timestamp to date +%s (real wall-clock) on every start, so the gap between the L2 genesis and its L1 origin block grows by one day for every day that passes since the L1 image was built. Once that drift exceeded rollup.json's max_sequencer_drift (600s), op-node's sequencer could never find a valid L1 origin for the first post-genesis block and the L2 chain stalled forever at block 0 -- surfacing as "wait for MintableERC20 deployment: context deadline exceeded" during LoadEnv, since op-pp's L1 snapshot is from Feb 2026 (~6 months of drift by now). Fix: read L1's actual head timestamp and use it to patch the L2 genesis instead of wall-clock time, keeping L2 genesis anchored to L1's frozen origin regardless of what day the test actually runs. Verified locally: op-geth-001/op-node-001 went from stuck at block 0 to actively sequencing new L2 blocks. Note: since L1 never advances, the chain still stalls again once L2's virtual time drifts past max_sequencer_drift from the anchored origin (~1800s of L2 time in local testing) -- well past LoadEnv/MintableERC20 deployment, but a longer-running test could still hit it. Left as a known follow-up rather than widening scope here. --- .../op-pp/config/001/op-geth-entrypoint.sh | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh b/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh index e05ade8d5..282d9fd26 100755 --- a/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh +++ b/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh @@ -44,10 +44,33 @@ echo "=== Patching L2 genesis timestamp ===" # Copy genesis to writable location cp /genesis-ro/l2-genesis.json /tmp/genesis.json -# Patch timestamp to current time (hex) -NOW=$(date +%s) +# Anchor the L2 genesis timestamp to L1's current head instead of wall-clock time. L1's chain +# data is baked into its image at build time and never advances past that snapshot, so using +# date +%s here would make the L2 genesis drift further from its L1 origin every day that +# passes since the image was built -- once that drift exceeds rollup.json's +# max_sequencer_drift, op-node's sequencer can never find a valid L1 origin for the first +# post-genesis block and the L2 chain stalls forever. +echo "Fetching L1 head timestamp to anchor L2 genesis..." +L1_TS_HEX="" +RETRIES=0 +while [ -z "$L1_TS_HEX" ] && [ $RETRIES -lt 30 ]; do + RESP=$(wget -qO- --header="Content-Type: application/json" \ + --post-data='{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":1}' \ + http://geth:8545 2>/dev/null || true) + L1_TS_HEX=$(echo "$RESP" | jq -r '.result.timestamp // empty') + if [ -z "$L1_TS_HEX" ]; then + RETRIES=$((RETRIES + 1)) + sleep 1 + fi +done +if [ -z "$L1_TS_HEX" ]; then + echo "ERROR: could not fetch L1 head timestamp to anchor L2 genesis" + exit 1 +fi + +NOW=$((L1_TS_HEX)) NOW_HEX=$(printf '0x%x' "$NOW") -echo "Patching L2 genesis timestamp to $NOW ($NOW_HEX)" +echo "Patching L2 genesis timestamp to L1 head time $NOW ($NOW_HEX)" jq --arg ts "$NOW_HEX" '.timestamp = $ts' /tmp/genesis.json > /tmp/genesis-patched.json mv /tmp/genesis-patched.json /tmp/genesis.json From ffda100469dbad6f4da528ba9b945f927e6d6f0f Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:00:59 +0200 Subject: [PATCH 02/16] fix(e2e/op-pp): raise max_sequencer_drift to avoid stall after ~30min L1's chain data is baked into its image at build time and never advances past block 384. Anchoring L2 genesis to L1's head (previous commit) fixes LoadEnv, but once the sequencer has produced ~600s (max_sequencer_drift) worth of L2 blocks since genesis, op-node's origin-selector needs a newer L1 origin than block 384 to keep going and never finds one, stalling the chain forever mid-test-run. Raise max_sequencer_drift to a week so the sequencer never needs to look for a newer L1 origin within the lifetime of a test run. Found while investigating CI failures on #1810. --- test/e2e/envs/op-pp/config/001/rollup.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/envs/op-pp/config/001/rollup.json b/test/e2e/envs/op-pp/config/001/rollup.json index a9baf2efb..e71481f66 100644 --- a/test/e2e/envs/op-pp/config/001/rollup.json +++ b/test/e2e/envs/op-pp/config/001/rollup.json @@ -21,7 +21,7 @@ } }, "block_time": 1, - "max_sequencer_drift": 600, + "max_sequencer_drift": 604800, "seq_window_size": 3600, "channel_timeout": 300, "l1_chain_id": 271828, From c4df2b4b064ab622a5a4c0c3cd470cfb714a933d Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:36:35 +0200 Subject: [PATCH 03/16] Revert "fix(e2e/op-pp): raise max_sequencer_drift to avoid stall after ~30min" This reverts commit bf18a77a5280f668ef2d025f9944f234ef0fe327. --- test/e2e/envs/op-pp/config/001/rollup.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/envs/op-pp/config/001/rollup.json b/test/e2e/envs/op-pp/config/001/rollup.json index e71481f66..a9baf2efb 100644 --- a/test/e2e/envs/op-pp/config/001/rollup.json +++ b/test/e2e/envs/op-pp/config/001/rollup.json @@ -21,7 +21,7 @@ } }, "block_time": 1, - "max_sequencer_drift": 604800, + "max_sequencer_drift": 600, "seq_window_size": 3600, "channel_timeout": 300, "l1_chain_id": 271828, From aad70760ad865aaf09d7767f556d796a15e37d1d Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:36:36 +0200 Subject: [PATCH 04/16] Revert "fix(e2e/op-pp): anchor L2 genesis timestamp to L1 head instead of wall-clock" This reverts commit e01e556bf7bee9e2ace8ee4fd4faf1e3a06e0660. --- .../op-pp/config/001/op-geth-entrypoint.sh | 29 ++----------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh b/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh index 282d9fd26..e05ade8d5 100755 --- a/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh +++ b/test/e2e/envs/op-pp/config/001/op-geth-entrypoint.sh @@ -44,33 +44,10 @@ echo "=== Patching L2 genesis timestamp ===" # Copy genesis to writable location cp /genesis-ro/l2-genesis.json /tmp/genesis.json -# Anchor the L2 genesis timestamp to L1's current head instead of wall-clock time. L1's chain -# data is baked into its image at build time and never advances past that snapshot, so using -# date +%s here would make the L2 genesis drift further from its L1 origin every day that -# passes since the image was built -- once that drift exceeds rollup.json's -# max_sequencer_drift, op-node's sequencer can never find a valid L1 origin for the first -# post-genesis block and the L2 chain stalls forever. -echo "Fetching L1 head timestamp to anchor L2 genesis..." -L1_TS_HEX="" -RETRIES=0 -while [ -z "$L1_TS_HEX" ] && [ $RETRIES -lt 30 ]; do - RESP=$(wget -qO- --header="Content-Type: application/json" \ - --post-data='{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":1}' \ - http://geth:8545 2>/dev/null || true) - L1_TS_HEX=$(echo "$RESP" | jq -r '.result.timestamp // empty') - if [ -z "$L1_TS_HEX" ]; then - RETRIES=$((RETRIES + 1)) - sleep 1 - fi -done -if [ -z "$L1_TS_HEX" ]; then - echo "ERROR: could not fetch L1 head timestamp to anchor L2 genesis" - exit 1 -fi - -NOW=$((L1_TS_HEX)) +# Patch timestamp to current time (hex) +NOW=$(date +%s) NOW_HEX=$(printf '0x%x' "$NOW") -echo "Patching L2 genesis timestamp to L1 head time $NOW ($NOW_HEX)" +echo "Patching L2 genesis timestamp to $NOW ($NOW_HEX)" jq --arg ts "$NOW_HEX" '.timestamp = $ts' /tmp/genesis.json > /tmp/genesis-patched.json mv /tmp/genesis-patched.json /tmp/genesis.json From 8ecf96bb771ba1bdd7361214bfd6370fad659ee9 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:19:08 +0200 Subject: [PATCH 05/16] fix(bridgetracker): resolve settled GER when settlement tx has no UpdateL1InfoTree event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1811. A cert's settlement tx on L1 doesn't always emit UpdateL1InfoTree itself — when the settlement doesn't move the GER, it just propagates whatever GER an earlier update already established. StepWaitL1SettledGER treated the missing event as "not ready yet" and stalled forever instead of recognizing this case. SettlementSource now: - Fails fast (domain.ErrBadSettlementTx, permanent) when the receipt is missing VerifyBatchesTrustedAggregator, instead of silently returning "not ready". - When UpdateL1InfoTree is missing, walks L1 backwards in bounded chunks (findEventUpdateL1InfoTreeBackwards) to find the closest earlier UpdateL1InfoTree event and uses its GER. - Requires the L1 GlobalExitRoot contract address (NewSettlementSource) to scope that backwards search. resolve_steps.UpdateStep now distinguishes permanent step failures (IsPermanent) from transient ones: a permanent error marks the step StepErrorPermanent immediately instead of accumulating a retry history that will never be retried. L1SettledGERResult now carries where each piece of evidence was found (SettlementBlockNumber/SettlementLogIndex, GERBlockNumber/GERLogIndex) instead of a single BlockNumber, since the GER-producing event can now live in a different block than the settlement tx itself. Also logs the set of resolved network entries once bridgeservicefinder finishes building its initial cache, to aid diagnosing network-resolution issues like the one reported in bali. Co-Authored-By: Claude Sonnet 5 --- bridgeservicefinder/bridgeservicefinder.go | 6 +- bridgetracker/api/bridge_step_path_test.go | 8 +- .../resolve_step_wait_l1_settled_ger.go | 15 +- bridgetracker/domain/resolve_steps.go | 25 ++- bridgetracker/domain/resolve_steps_test.go | 54 +++++- bridgetracker/engine_test.go | 2 +- bridgetracker/sources/settlement.go | 113 +++++++++++- bridgetracker/sources/settlement_test.go | 173 ++++++++++++++++-- bridgetracker/types/status.go | 26 ++- proxy/cmd/run.go | 3 +- 10 files changed, 373 insertions(+), 52 deletions(-) diff --git a/bridgeservicefinder/bridgeservicefinder.go b/bridgeservicefinder/bridgeservicefinder.go index 68bbbaa5c..95d4d0914 100644 --- a/bridgeservicefinder/bridgeservicefinder.go +++ b/bridgeservicefinder/bridgeservicefinder.go @@ -187,7 +187,11 @@ func (f *finder) Start(ctx context.Context) error { if err := f.buildInitialCache(ctx); err != nil { return err } - + listNetworksStr := "" + for networkID := range f.cache.entries { + listNetworksStr += fmt.Sprintf("%d, ", networkID) + } + f.logger.Info("Resolved network entries: " + listNetworksStr) unhealthy := f.probeAll(ctx) if unhealthy > 0 && f.cfg.RequireAllHealthyOnStart { return fmt.Errorf("%w: %d unreachable", ErrServicesUnhealthyOnStart, unhealthy) diff --git a/bridgetracker/api/bridge_step_path_test.go b/bridgetracker/api/bridge_step_path_test.go index db25d1498..fe3d1dce9 100644 --- a/bridgetracker/api/bridge_step_path_test.go +++ b/bridgetracker/api/bridge_step_path_test.go @@ -142,13 +142,17 @@ func TestBridgeStepPathResultMarshalJSON(t *testing.T) { { name: "L1 settled GER result", result: &types.L1SettledGERResult{ - TxHash: common.HexToHash("0x0d"), BlockNumber: 400, GER: common.HexToHash("0x0e"), + TxHash: common.HexToHash("0x0d"), SettlementBlockNumber: 400, SettlementLogIndex: 1, + GER: common.HexToHash("0x0e"), GERBlockNumber: 400, GERLogIndex: 2, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, expected: `{ "tx_hash":"0x000000000000000000000000000000000000000000000000000000000000000d", - "block_number":400, + "settlement_block_number":400, + "settlement_log_index":1, "ger":"0x000000000000000000000000000000000000000000000000000000000000000e", + "ger_block_number":400, + "ger_log_index":2, "has_verify_batches_trusted_aggregator":true, "has_update_l1_info_tree":true, "has_update_l1_info_tree_v2":false diff --git a/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go b/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go index 4b2d5c036..92149a3fd 100644 --- a/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go +++ b/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go @@ -2,6 +2,7 @@ package domain import ( "context" + "errors" "fmt" "github.com/agglayer/aggkit/bridgetracker/types" @@ -9,11 +10,15 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// ErrLeafIndexNotResolved means the settlement tx is confirmed but its GER has not been -// resolved to an L1 info tree leaf index yet (only reached when UpdateL1InfoTreeV2 did not fire -// — see WaitL1SettledGERResolver): the same "not ready" family as ErrStepPending (errors.Is -// matches both), but carries the settlement evidence gathered so far as its Result -var ErrLeafIndexNotResolved = fmt.Errorf("settlement GER not resolved to a leaf index yet: %w", ErrStepPending) +var ( + // ErrLeafIndexNotResolved means the settlement tx is confirmed but its GER has not been + // resolved to an L1 info tree leaf index yet (only reached when UpdateL1InfoTreeV2 did not fire + // — see WaitL1SettledGERResolver): the same "not ready" family as ErrStepPending (errors.Is + // matches both), but carries the settlement evidence gathered so far as its Result + ErrLeafIndexNotResolved = fmt.Errorf("settlement GER not resolved to a leaf index yet: %w", ErrStepPending) + + ErrBadSettlementTx = Permanent(errors.New("settlement tx receipt does not carry required events")) +) // SettlementSource is the driven port to the L1 evidence a certificate's settlement produces type SettlementSource interface { diff --git a/bridgetracker/domain/resolve_steps.go b/bridgetracker/domain/resolve_steps.go index 4b0bf0158..772431c22 100644 --- a/bridgetracker/domain/resolve_steps.go +++ b/bridgetracker/domain/resolve_steps.go @@ -89,12 +89,15 @@ func currentStepIndex(steps []BridgeStepPath) int { // the current step still in progress (StartDate stamped if not already) otherwise — either way // result becomes its Result, so a resolver can surface data before its milestone is fully met // (see ErrCertificateNotSettled). If stepErr is non-nil, the step is instead marked -// StepStatusError, accumulating stepErr onto its retry count and description rather than -// discarding the history of a transient source failure — the step-level counterpart of the -// tx-level error handling in ResolveBridgeTx; complete is meaningless in that case (a step -// cannot both fail and complete) and idx+1 is left untouched. With stepErr nil, any previous -// Error is cleared instead: a successful fact check, even an inconclusive one, clears a previous -// transient failure, evidence the retry is working, not just that a milestone was met. +// StepStatusError — the step-level counterpart of the tx-level error handling in +// ResolveBridgeTx. A resolver marks stepErr as unrecoverable the same way a BridgeEventSource +// does (see Permanent/IsPermanent): IsPermanent(stepErr) makes the step StepErrorPermanent with +// just this failure, no point accumulating a retry history nothing will retry. Any other stepErr +// is StepErrorTransient, accumulating onto the step's retry count and description instead of +// discarding the history of a transient source failure. Either way complete is meaningless here +// (a step cannot both fail and complete) and idx+1 is left untouched. With stepErr nil, any +// previous Error is cleared instead: a successful fact check, even an inconclusive one, clears a +// previous transient failure, evidence the retry is working, not just that a milestone was met. // Completing idx opens idx+1 as the new current step (InProgress), completing it immediately, // terminal, if it is StepClaimed — a step that never has a fact check of its own. Returns // tracking unchanged only when there is truly nothing new to record: not complete, no stepErr, @@ -118,12 +121,20 @@ func UpdateStep( current.SetResult(result) switch { case stepErr != nil: + current.Status = types.StepStatusError + if IsPermanent(stepErr) { + // unrecoverable: no retry history to accumulate, nothing will retry this step + current.Error = &types.ErrorStep{ + ErrorType: types.StepErrorPermanent, + Description: []string{stepErr.Error()}, + } + break + } retryCount, description := 1, []string{stepErr.Error()} if current.Error != nil { retryCount = current.Error.RetryCount + 1 description = append(append([]string{}, current.Error.Description...), stepErr.Error()) } - current.Status = types.StepStatusError current.Error = &types.ErrorStep{ ErrorType: types.StepErrorTransient, RetryCount: retryCount, diff --git a/bridgetracker/domain/resolve_steps_test.go b/bridgetracker/domain/resolve_steps_test.go index e017e81ca..d802056b2 100644 --- a/bridgetracker/domain/resolve_steps_test.go +++ b/bridgetracker/domain/resolve_steps_test.go @@ -145,7 +145,7 @@ func TestResolveSteps(t *testing.T) { settledCert := &types.CertificateInclusionData{CertificateData: settledCertData} settlementLeafIndex := uint32(7) settlementResult := &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, + TxHash: settlementTxHash, SettlementBlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, } claim := &types.ClaimResult{ClaimTx: common.Hash{3}, BlockNumber: 300} @@ -301,7 +301,7 @@ func TestResolveSteps(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, @@ -311,7 +311,7 @@ func TestResolveSteps(t *testing.T) { }, resultOf: types.StepWaitL1SettledGER, result: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, @@ -324,7 +324,7 @@ func TestResolveSteps(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, l1InfoTreeIndex: &settlementLeafIndex, @@ -336,7 +336,7 @@ func TestResolveSteps(t *testing.T) { }, resultOf: types.StepWaitL1SettledGER, result: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, + TxHash: settlementTxHash, SettlementBlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, @@ -705,6 +705,50 @@ func TestUpdateStep(t *testing.T) { require.Equal(t, 2, sp.Error.RetryCount) require.Equal(t, []string{errFakeUpdateStep.Error(), errFakeUpdateStep.Error()}, sp.Error.Description) }) + + t.Run("a stepErr wrapped as Permanent marks the step StepErrorPermanent with no retry count", func(t *testing.T) { + t.Parallel() + + tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ + {Step: types.StepWaitingGERUpdate, Status: types.StepStatusInProgress, StartDate: &t1}, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusPending}, + {Step: types.StepWaitingClaim, Status: types.StepStatusPending}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, t1) + + advanced := UpdateStep(tracking, 0, nil, false, Permanent(errFakeUpdateStep), t2) + + sp := advanced.AllSteps()[0] + require.Equal(t, types.StepStatusError, sp.Status) + require.NotNil(t, sp.Error) + require.Equal(t, types.StepErrorPermanent, sp.Error.ErrorType) + require.Equal(t, 0, sp.Error.RetryCount) + require.Equal(t, []string{errFakeUpdateStep.Error()}, sp.Error.Description) + }) + + t.Run("a repeated Permanent stepErr does not accumulate onto a previous transient history", func(t *testing.T) { + t.Parallel() + + tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ + { + Step: types.StepWaitingGERUpdate, Status: types.StepStatusError, StartDate: &t1, + Error: &types.ErrorStep{ + ErrorType: types.StepErrorTransient, RetryCount: 3, + Description: []string{errFakeUpdateStep.Error(), errFakeUpdateStep.Error(), errFakeUpdateStep.Error()}, + }, + }, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusPending}, + {Step: types.StepWaitingClaim, Status: types.StepStatusPending}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, t1) + + advanced := UpdateStep(tracking, 0, nil, false, Permanent(errFakeUpdateStep), t2) + + sp := advanced.AllSteps()[0] + require.Equal(t, types.StepErrorPermanent, sp.Error.ErrorType) + require.Equal(t, 0, sp.Error.RetryCount, "nothing will retry a permanent step, so the count resets") + require.Equal(t, []string{errFakeUpdateStep.Error()}, sp.Error.Description) + }) } // TestCertificateResolverSkipsWaypoints pins that a certificate observed already Settled, with diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 0f5273cec..817bfa108 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -542,7 +542,7 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { settlementLeafIndex := uint32(7) f.settlement = &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 2000, GER: common.HexToHash("0x0b"), + TxHash: settlementTxHash, SettlementBlockNumber: 2000, GER: common.HexToHash("0x0b"), L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, } diff --git a/bridgetracker/sources/settlement.go b/bridgetracker/sources/settlement.go index d2140be67..b2821e6bb 100644 --- a/bridgetracker/sources/settlement.go +++ b/bridgetracker/sources/settlement.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "math/big" "github.com/agglayer/aggkit/bridgetracker" + "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum" @@ -13,6 +15,13 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) +// l1InfoTreeBackwardsSearchChunkSize bounds how many blocks each FilterLogs call in +// findEventUpdateL1InfoTreeBackwards covers: most RPC providers cap how wide a single +// eth_getLogs range can be, so a search spanning the chain's whole history has to walk +// backwards one chunk at a time instead of in a single call. Same chunk size aggkit already +// uses elsewhere for bounded log queries (e.g. bridgeservicefinder.DefaultBlockChunkSize) +const l1InfoTreeBackwardsSearchChunkSize = 10_000 + var ( // updateL1InfoTreeSignature is the topic0 of the GlobalExitRoot contract's UpdateL1InfoTree // event (same signature l1infotreesync matches) @@ -39,12 +48,24 @@ type SettlementSource struct { // never re-checked (TrackingBridgeTx.IsDone), so accepting a receipt that later gets // reorged out would otherwise be permanent l1Finality aggkittypes.BlockNumberFinality + // contractGlobalExitRootAddress is the L1 GlobalExitRoot contract address + // findEventUpdateL1InfoTreeBackwards reads UpdateL1InfoTree logs from, for a settlement tx + // whose own receipt does not carry the event + contractGlobalExitRootAddress common.Address } // NewSettlementSource returns a SettlementSource resolving the L1 (network 0) JSON-RPC client -// through clients, accepting a settlement tx's receipt only once it reaches l1Finality -func NewSettlementSource(clients EthClientResolver, l1Finality aggkittypes.BlockNumberFinality) *SettlementSource { - return &SettlementSource{clients: clients, l1Finality: l1Finality} +// through clients, accepting a settlement tx's receipt only once it reaches l1Finality. +// contractGlobalExitRootAddress is the L1 GlobalExitRoot contract findEventUpdateL1InfoTreeBackwards +// searches when a settlement tx's own receipt does not carry an UpdateL1InfoTree event +func NewSettlementSource( + clients EthClientResolver, l1Finality aggkittypes.BlockNumberFinality, contractGlobalExitRootAddress common.Address, +) *SettlementSource { + return &SettlementSource{ + clients: clients, + l1Finality: l1Finality, + contractGlobalExitRootAddress: contractGlobalExitRootAddress, + } } // SettlementGERUpdate implements bridgetracker.SettlementSource: it fetches settlementTxHash's @@ -76,7 +97,10 @@ func (s *SettlementSource) SettlementGERUpdate( return nil, nil // mined, but not yet at the required finality } - result := &trackertypes.L1SettledGERResult{TxHash: settlementTxHash, BlockNumber: receipt.BlockNumber.Uint64()} + result := &trackertypes.L1SettledGERResult{ + TxHash: settlementTxHash, + SettlementBlockNumber: receipt.BlockNumber.Uint64(), + } for _, l := range receipt.Logs { if len(l.Topics) == 0 { continue @@ -84,6 +108,7 @@ func (s *SettlementSource) SettlementGERUpdate( switch l.Topics[0] { case verifyBatchesTrustedAggregatorSignature: result.HasVerifyBatchesTrustedAggregator = true + result.SettlementLogIndex = l.Index case updateL1InfoTreeSignature: // both mainnetExitRoot and rollupExitRoot are indexed bytes32 params, so they sit // directly in the topics (fixed-size indexed values are not hashed, unlike dynamic @@ -94,6 +119,8 @@ func (s *SettlementSource) SettlementGERUpdate( mainnetExitRoot, rollupExitRoot := l.Topics[1], l.Topics[2] result.HasUpdateL1InfoTree = true result.GER = crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]) + result.GERBlockNumber = receipt.BlockNumber.Uint64() + result.GERLogIndex = l.Index case updateL1InfoTreeV2Signature: result.HasUpdateL1InfoTreeV2 = true // leafCount is the only indexed param, so it sits directly in Topics[1] (a uint32 @@ -110,8 +137,82 @@ func (s *SettlementSource) SettlementGERUpdate( } } - if !result.HasVerifyBatchesTrustedAggregator || !result.HasUpdateL1InfoTree { - return nil, nil // mandatory evidence not there yet + if !result.HasVerifyBatchesTrustedAggregator { + return nil, fmt.Errorf("settlement tx %s receipt does not carry VerifyBatchesTrustedAggregator: %w", + settlementTxHash, domain.ErrBadSettlementTx) + } + // This cert's settlement did not move the GER itself (no UpdateL1InfoTree in its own + // receipt): the value it propagated is whichever one an earlier settlement already + // established, so look backwards on L1 for that event + if !result.HasUpdateL1InfoTree { + event, err := s.findEventUpdateL1InfoTreeBackwards(ctx, client, receipt.BlockNumber.Uint64()) + if err != nil { + return nil, err + } + result.GER = event.GER + result.GERBlockNumber = event.BlockNumber + result.GERLogIndex = event.LogIndex } + return result, nil } + +// updateL1InfoTreeEvent is the GER-relevant evidence of a single UpdateL1InfoTree event: the +// GER it produced and where on L1 it landed +type updateL1InfoTreeEvent struct { + GER common.Hash + BlockNumber uint64 + LogIndex uint +} + +// findEventUpdateL1InfoTreeBackwards looks for the most recent UpdateL1InfoTree event on the +// GlobalExitRoot contract at or before fromBlock, walking backwards in +// l1InfoTreeBackwardsSearchChunkSize chunks until one is found or block 0 is reached. Only +// called when the settlement tx's own receipt does not carry the event (see +// SettlementGERUpdate): the L1 Global Exit Root is never unset, so some earlier update always +// exists, unless the settlement tx is not what it claims to be, in which case this returns +// domain.ErrBadSettlementTx (Permanent — see the sentinel's own doc) +func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( + ctx context.Context, client aggkittypes.BaseEthereumClienter, fromBlock uint64, +) (*updateL1InfoTreeEvent, error) { + toBlock := fromBlock + for { + fromBlockChunk := uint64(0) + if toBlock > l1InfoTreeBackwardsSearchChunkSize { + fromBlockChunk = toBlock - l1InfoTreeBackwardsSearchChunkSize + } + + logs, err := client.FilterLogs(ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromBlockChunk), + ToBlock: new(big.Int).SetUint64(toBlock), + Addresses: []common.Address{s.contractGlobalExitRootAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }) + if err != nil { + return nil, fmt.Errorf("fetching UpdateL1InfoTree logs from block %d to %d: %w", + fromBlockChunk, toBlock, err) + } + + if len(logs) > 0 { + // FilterLogs returns logs in ascending block/log-index order, so the last one is the + // most recent event in this chunk — the closest one at or before fromBlock + last := logs[len(logs)-1] + if len(last.Topics) < 3 { //nolint:mnd + return nil, fmt.Errorf("UpdateL1InfoTree log at block %d missing its exit root topics: %w", + last.BlockNumber, domain.ErrBadSettlementTx) + } + mainnetExitRoot, rollupExitRoot := last.Topics[1], last.Topics[2] + return &updateL1InfoTreeEvent{ + GER: crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]), + BlockNumber: last.BlockNumber, + LogIndex: last.Index, + }, nil + } + + if fromBlockChunk == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTree event found at or before block %d: %w", + fromBlock, domain.ErrBadSettlementTx) + } + toBlock = fromBlockChunk - 1 + } +} diff --git a/bridgetracker/sources/settlement_test.go b/bridgetracker/sources/settlement_test.go index 0d17ae811..8a31e1ac9 100644 --- a/bridgetracker/sources/settlement_test.go +++ b/bridgetracker/sources/settlement_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/agglayer/aggkit/bridgetracker" + "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkittypes "github.com/agglayer/aggkit/types" "github.com/agglayer/aggkit/types/mocks" @@ -17,8 +18,28 @@ import ( "github.com/stretchr/testify/require" ) +// testGERAddress is the canned GlobalExitRoot contract address findEventUpdateL1InfoTreeBackwards +// filters by in these tests +var testGERAddress = common.HexToAddress("0xge2") + func newSettlementSource(client *mocks.BaseEthereumClienter) *SettlementSource { - return NewSettlementSource(StaticClients{0: client}, aggkittypes.FinalizedBlock) + return NewSettlementSource(StaticClients{0: client}, aggkittypes.FinalizedBlock, testGERAddress) +} + +// expectBackwardsUpdateL1InfoTreeLog stubs client's FilterLogs to answer +// findEventUpdateL1InfoTreeBackwards' very first chunk (fromBlock down to fromBlock minus +// l1InfoTreeBackwardsSearchChunkSize, or 0) with a single matching log +func expectBackwardsUpdateL1InfoTreeLog(client *mocks.BaseEthereumClienter, fromBlock uint64, log gethtypes.Log) { + fromChunk := uint64(0) + if fromBlock > l1InfoTreeBackwardsSearchChunkSize { + fromChunk = fromBlock - l1InfoTreeBackwardsSearchChunkSize + } + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromChunk), + ToBlock: new(big.Int).SetUint64(fromBlock), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{log}, nil) } // mainnetExitRoot/rollupExitRoot are the canned exit roots the tests use to build an @@ -47,7 +68,7 @@ func TestSettlementSourceBothMandatoryEventsPresent(t *testing.T) { t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) require.Equal(t, &trackertypes.L1SettledGERResult{ - TxHash: testTxHash, BlockNumber: 12345, GER: wantGER, + TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, GERBlockNumber: 12345, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, result) } @@ -102,7 +123,8 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { // TestSettlementSourceMalformedUpdateL1InfoTreeLog pins that an UpdateL1InfoTree log missing its // indexed topics (fewer than 3 total) is not mistaken for a match: without both exit roots there -// is no GER to compute, so the mandatory event is treated as not there yet +// is no GER to compute from it, so it is treated the same as the event being altogether absent +// from this receipt — falling back to findEventUpdateL1InfoTreeBackwards for an earlier one func TestSettlementSourceMalformedUpdateL1InfoTreeLog(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ @@ -113,27 +135,32 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeLog(t *testing.T) { }, }, nil) expectFinalized(client, 12345) + expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ + BlockNumber: 12000, Index: 3, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) - require.Nil(t, result) + require.Equal(t, &trackertypes.L1SettledGERResult{ + TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, + GERBlockNumber: 12000, GERLogIndex: 3, + HasVerifyBatchesTrustedAggregator: true, + }, result) } -func TestSettlementSourceMissingMandatoryEvent(t *testing.T) { +// TestSettlementSourceMissingVerifyBatchesTrustedAggregator pins that a finalized settlement tx +// receipt missing VerifyBatchesTrustedAggregator is a permanent failure (domain.ErrBadSettlementTx): +// unlike a not-yet-finalized receipt, this one is already final and simply does not carry an +// event a genuine settlement always emits, so retrying it can never change the outcome +func TestSettlementSourceMissingVerifyBatchesTrustedAggregator(t *testing.T) { testCases := []struct { name string logs []*gethtypes.Log }{ {name: "no logs at all"}, - { - name: "only VerifyBatchesTrustedAggregator", - logs: []*gethtypes.Log{{Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}}, - }, - { - name: "only UpdateL1InfoTree", - logs: []*gethtypes.Log{updateL1InfoTree}, - }, + {name: "only UpdateL1InfoTree", logs: []*gethtypes.Log{updateL1InfoTree}}, { name: "only the optional UpdateL1InfoTreeV2", logs: []*gethtypes.Log{{Topics: []common.Hash{updateL1InfoTreeV2Signature}}}, @@ -150,12 +177,126 @@ func TestSettlementSourceMissingMandatoryEvent(t *testing.T) { result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) - require.NoError(t, err) - require.Nil(t, result, "mandatory evidence missing, still pending") + require.ErrorIs(t, err, domain.ErrBadSettlementTx) + require.Nil(t, result) }) } } +// TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent pins that a settlement tx +// whose own receipt carries VerifyBatchesTrustedAggregator but not UpdateL1InfoTree is not +// rejected outright: the settlement simply did not move the GER itself, so +// findEventUpdateL1InfoTreeBackwards supplies the GER an earlier settlement already established +func TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), + Logs: []*gethtypes.Log{ + {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, + }, + }, nil) + expectFinalized(client, 12345) + expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ + BlockNumber: 12000, Index: 3, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }) + + result, err := newSettlementSource(client).SettlementGERUpdate( + t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) + require.NoError(t, err) + require.Equal(t, &trackertypes.L1SettledGERResult{ + TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, + GERBlockNumber: 12000, GERLogIndex: 3, + HasVerifyBatchesTrustedAggregator: true, + }, result) +} + +// TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent pins that when +// findEventUpdateL1InfoTreeBackwards finds nothing either, SettlementGERUpdate surfaces its +// domain.ErrBadSettlementTx rather than swallowing it +func TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(5000), + Logs: []*gethtypes.Log{ + {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, + }, + }, nil) + expectFinalized(client, 5000) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + + result, err := newSettlementSource(client).SettlementGERUpdate( + t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) + require.Nil(t, result) +} + +// TestFindEventUpdateL1InfoTreeBackwardsPaginates pins that an empty first chunk does not stop +// the search: it keeps walking further back in l1InfoTreeBackwardsSearchChunkSize chunks until +// one of them carries a match +func TestFindEventUpdateL1InfoTreeBackwardsPaginates(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(15000), ToBlock: big.NewInt(25000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(4999), ToBlock: big.NewInt(14999), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{ + {BlockNumber: 10000, Index: 2, Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}}, + }, nil) + + event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 25000) + require.NoError(t, err) + require.Equal(t, &updateL1InfoTreeEvent{GER: wantGER, BlockNumber: 10000, LogIndex: 2}, event) +} + +// TestFindEventUpdateL1InfoTreeBackwardsNotFound pins that reaching block 0 without a match is +// domain.ErrBadSettlementTx: the L1 Global Exit Root is never unset, so its absence anywhere +// before fromBlock means the settlement tx is not what it claims to be +func TestFindEventUpdateL1InfoTreeBackwardsNotFound(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) +} + +// TestFindEventUpdateL1InfoTreeBackwardsMalformedLog pins that a matched log missing its +// indexed exit root topics is a domain.ErrBadSettlementTx too, rather than being silently +// skipped: a genuine UpdateL1InfoTree event always carries both, so this signals corrupt data, +// not something an earlier chunk would fix +func TestFindEventUpdateL1InfoTreeBackwardsMalformedLog(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {BlockNumber: 4000, Topics: []common.Hash{updateL1InfoTreeSignature}}, + }, nil) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) +} + +// TestFindEventUpdateL1InfoTreeBackwardsFetchError pins that a FilterLogs failure propagates +// as-is (transient, retried by the engine), not wrapped as domain.ErrBadSettlementTx +func TestFindEventUpdateL1InfoTreeBackwardsFetchError(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return(nil, errors.New("rpc down")) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + require.ErrorContains(t, err, "rpc down") +} + func TestSettlementSourceTxNotFound(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(nil, ethereum.NotFound) @@ -193,7 +334,7 @@ func TestSettlementSourceReceiptFetchError(t *testing.T) { } func TestSettlementSourceUnknownNetwork(t *testing.T) { - source := NewSettlementSource(StaticClients{}, aggkittypes.FinalizedBlock) + source := NewSettlementSource(StaticClients{}, aggkittypes.FinalizedBlock, testGERAddress) _, err := source.SettlementGERUpdate(t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.ErrorContains(t, err, "network 0") diff --git a/bridgetracker/types/status.go b/bridgetracker/types/status.go index 533220986..9533d66ab 100644 --- a/bridgetracker/types/status.go +++ b/bridgetracker/types/status.go @@ -289,17 +289,27 @@ type ClaimResult struct { // L1SettledGERResult is the result of StepWaitL1SettledGER once it completes: the evidence, // read off the certificate's settlement tx receipt on L1, that the settlement propagated to // the L1 Global Exit Root. HasVerifyBatchesTrustedAggregator and HasUpdateL1InfoTree are both -// required for the step to complete; HasUpdateL1InfoTreeV2 is only informational. GER is the -// Global Exit Root produced by the settlement (computed from UpdateL1InfoTree's mainnet/rollup -// exit roots), used by StepWaitingGERInjection to check whether it has reached the destination. -// L1InfoTreeIndex is the leaf index GER landed at: populated straight from UpdateL1InfoTreeV2's -// LeafCount when that (optional) event fires, otherwise resolved by the step itself with one -// extra lookup (GER -> leaf) before it can complete — either way, by the time this step is -// Done, L1InfoTreeIndex is never nil +// required for the step to complete; HasUpdateL1InfoTreeV2 is only informational. +// SettlementBlockNumber/SettlementLogIndex locate the settlement tx's own +// VerifyBatchesTrustedAggregator log — the event that confirms this tx is a genuine +// certificate settlement. GER is the Global Exit Root produced by the settlement (computed +// from UpdateL1InfoTree's mainnet/rollup exit roots), used by StepWaitingGERInjection to check +// whether it has reached the destination. GERBlockNumber/GERLogIndex locate the +// UpdateL1InfoTree event GER was computed from: normally the same block as the settlement +// (HasUpdateL1InfoTree true), but when the settlement tx's own receipt does not carry the +// event (the settlement did not move the GER itself), they instead point to the closest +// earlier one on L1 (see sources.SettlementSource.findEventUpdateL1InfoTreeBackwards), whose +// GER is still the one this settlement propagated. L1InfoTreeIndex is the leaf index GER +// landed at: populated straight from UpdateL1InfoTreeV2's LeafCount when that (optional) event +// fires, otherwise resolved by the step itself with one extra lookup (GER -> leaf) before it +// can complete — either way, by the time this step is Done, L1InfoTreeIndex is never nil type L1SettledGERResult struct { TxHash common.Hash `json:"tx_hash"` - BlockNumber uint64 `json:"block_number"` + SettlementBlockNumber uint64 `json:"settlement_block_number"` + SettlementLogIndex uint `json:"settlement_log_index"` GER common.Hash `json:"ger"` + GERBlockNumber uint64 `json:"ger_block_number"` + GERLogIndex uint `json:"ger_log_index"` L1InfoTreeIndex *uint32 `json:"l1_info_tree_index,omitempty"` HasVerifyBatchesTrustedAggregator bool `json:"has_verify_batches_trusted_aggregator"` HasUpdateL1InfoTree bool `json:"has_update_l1_info_tree"` diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 21d586373..2e27b5d44 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -204,7 +204,8 @@ func runTracker( WaitingGERUpdateSource: gerSource, LERs: sources.NewLERSource(rpcClients), Claims: sources.NewClaimSource(finder), - Settlement: sources.NewSettlementSource(rpcClients, trackerCfg.L1BlockFinality), + Settlement: sources.NewSettlementSource( + rpcClients, trackerCfg.L1BlockFinality, trackerCfg.L1GlobalExitRootAddress), }, ) if err != nil { From b48aae4bf6e7fe6242e786bbbb0be20549679fa7 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:41:25 +0200 Subject: [PATCH 06/16] feat(bridgetracker): add GET /activity/from/{from_address} endpoint Adds a new activity endpoint to the bridge tracker that answers "what bridges has this address sent, and what is their claim state", across every bridge service the bridgeservicefinder currently knows about rather than one network at a time: - bridgeservicefinder.Finder gains NetworkIDs(), enumerating every network currently resolved (i.e. every network GetURL would presently succeed for), backed by a new cache.networkIDs() read. - bridgetracker/domain/activity.go defines the ActivityEntry model and the driven ports (ActivityBridgeScanner, ActivityClaimChecker, ActivityQuerier) the endpoint depends on; bridgetracker/activity.go implements ActivityCache, composing a scan across networks with claim resolution and (optionally, via includeTracking=true) registering still-unclaimed bridges with the tracker. - bridgetracker/sources/activity.go implements ActivitySource, the adapter over the per-network bridge-service/JSON-RPC clients used elsewhere in the tracker. - bridgetracker/api/activity_command.go + api.go wire GET /tracker/v1/activity/from/{from_address}; the route is only registered when both Config.ActivityScanner and Config.ActivityClaims are set, so the endpoint is entirely opt-in. - proxy/cmd/run.go wires the new sources.ActivitySource into the tracker config using the existing finder/rpcClients/BridgeAddrs. - bridgetracker/types/claim_status.go adds the claim-status vocabulary shared between the activity endpoint and its sources. - Regenerated swagger docs (bridgetracker/api/docs, docs/assets/swagger/bridge_tracker) for the new route. - Mocks for the new ports generated under bridgetracker/mocks; unrelated autoclaim call sites updated for the new bridgeservicefinder.Finder.NetworkIDs() method on the interface. Co-Authored-By: Claude Opus 4.8 --- autoclaim/proof/leaf_proof_refresher_test.go | 8 + autoclaim/runtime/runtime.go | 2 + autoclaim/runtime/runtime_test.go | 2 + bridgeservicefinder/bridgeservicefinder.go | 6 + bridgeservicefinder/cache.go | 14 + bridgeservicefinder/cache_test.go | 43 +++ bridgeservicefinder/interfaces.go | 5 + bridgetracker/activity.go | 148 ++++++++ bridgetracker/activity_test.go | 204 +++++++++++ bridgetracker/api/activity_command.go | 115 ++++++ bridgetracker/api/api.go | 27 +- bridgetracker/api/docs/docs.go | 329 ++++++++++++++++-- bridgetracker/api/docs/swagger.json | 329 ++++++++++++++++-- bridgetracker/api/docs/swagger.yaml | 269 ++++++++++++-- bridgetracker/bridgetracker.go | 10 +- bridgetracker/bridgetracker_test.go | 61 ++++ bridgetracker/config.go | 8 + bridgetracker/domain/activity.go | 58 +++ .../mocks/mock_activity_bridge_scanner.go | 99 ++++++ .../mocks/mock_activity_claim_checker.go | 154 ++++++++ bridgetracker/mocks/mock_activity_querier.go | 100 ++++++ bridgetracker/ports.go | 14 + bridgetracker/sources/activity.go | 184 ++++++++++ bridgetracker/sources/activity_test.go | 236 +++++++++++++ bridgetracker/types/claim_status.go | 36 ++ .../swagger/bridge_tracker/swagger.json | 329 ++++++++++++++++-- proxy/cmd/run.go | 10 +- 27 files changed, 2700 insertions(+), 100 deletions(-) create mode 100644 bridgeservicefinder/cache_test.go create mode 100644 bridgetracker/activity.go create mode 100644 bridgetracker/activity_test.go create mode 100644 bridgetracker/api/activity_command.go create mode 100644 bridgetracker/domain/activity.go create mode 100644 bridgetracker/mocks/mock_activity_bridge_scanner.go create mode 100644 bridgetracker/mocks/mock_activity_claim_checker.go create mode 100644 bridgetracker/mocks/mock_activity_querier.go create mode 100644 bridgetracker/sources/activity.go create mode 100644 bridgetracker/sources/activity_test.go create mode 100644 bridgetracker/types/claim_status.go diff --git a/autoclaim/proof/leaf_proof_refresher_test.go b/autoclaim/proof/leaf_proof_refresher_test.go index 8b7d95095..aa64ae582 100644 --- a/autoclaim/proof/leaf_proof_refresher_test.go +++ b/autoclaim/proof/leaf_proof_refresher_test.go @@ -36,6 +36,14 @@ func (f *fakeURLResolver) GetURL(networkID uint32) (bridgeservicefinder.NetworkU return bridgeservicefinder.NetworkURLs{BridgeURL: f.urls[networkID]}, nil } +func (f *fakeURLResolver) NetworkIDs() []uint32 { + ids := make([]uint32, 0, len(f.urls)) + for id := range f.urls { + ids = append(ids, id) + } + return ids +} + // fakeClaimProofClient implements claimProofClient for tests, keyed by base URL. type fakeClaimProofClient struct { baseURL string diff --git a/autoclaim/runtime/runtime.go b/autoclaim/runtime/runtime.go index fec51b161..21bc857bc 100644 --- a/autoclaim/runtime/runtime.go +++ b/autoclaim/runtime/runtime.go @@ -536,6 +536,8 @@ func (noopBridgeServiceFinder) GetURL(networkID uint32) (bridgeservicefinder.Net networkID) } +func (noopBridgeServiceFinder) NetworkIDs() []uint32 { return nil } + // startRuntimeComponents launches the goroutines for tx managers, claimers, and the bridge detector. func startRuntimeComponents( ctx context.Context, diff --git a/autoclaim/runtime/runtime_test.go b/autoclaim/runtime/runtime_test.go index ea303a096..9972baadb 100644 --- a/autoclaim/runtime/runtime_test.go +++ b/autoclaim/runtime/runtime_test.go @@ -290,6 +290,8 @@ func (fakeBridgeServiceFinder) GetURL(uint32) (bridgeservicefinder.NetworkURLs, return bridgeservicefinder.NetworkURLs{BridgeURL: "http://fake-source"}, nil } +func (fakeBridgeServiceFinder) NetworkIDs() []uint32 { return nil } + func withL2ToLxEnabled(cfg autoclaimcfg.Config) autoclaimcfg.Config { cfg.L2ToLxBridgeDetector = autoclaimcfg.L2ToLxBridgeDetector{ Enabled: true, diff --git a/bridgeservicefinder/bridgeservicefinder.go b/bridgeservicefinder/bridgeservicefinder.go index 95d4d0914..86914482a 100644 --- a/bridgeservicefinder/bridgeservicefinder.go +++ b/bridgeservicefinder/bridgeservicefinder.go @@ -379,3 +379,9 @@ func (f *finder) GetURL(networkID uint32) (NetworkURLs, error) { return NetworkURLs{BridgeURL: entry.url, JSONRPCURL: entry.jsonRPCURL}, nil } + +// NetworkIDs returns the networkIDs of every network currently resolved (i.e. every network +// GetURL would presently succeed for). +func (f *finder) NetworkIDs() []uint32 { + return f.cache.networkIDs() +} diff --git a/bridgeservicefinder/cache.go b/bridgeservicefinder/cache.go index 7e08247b9..4d91e5101 100644 --- a/bridgeservicefinder/cache.go +++ b/bridgeservicefinder/cache.go @@ -49,3 +49,17 @@ func (c *cache) set(networkID uint32, entry cacheEntry) { c.entries[networkID] = entry } + +// networkIDs returns the networkIDs of every network currently cached. It takes a read lock so +// it is safe to call concurrently with set. +func (c *cache) networkIDs() []uint32 { + c.mu.RLock() + defer c.mu.RUnlock() + + ids := make([]uint32, 0, len(c.entries)) + for id := range c.entries { + ids = append(ids, id) + } + + return ids +} diff --git a/bridgeservicefinder/cache_test.go b/bridgeservicefinder/cache_test.go new file mode 100644 index 000000000..71ac886e3 --- /dev/null +++ b/bridgeservicefinder/cache_test.go @@ -0,0 +1,43 @@ +package bridgeservicefinder + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCache_NetworkIDs verifies networkIDs returns exactly the networkIDs currently cached, +// with no duplicates and regardless of insertion order. +func TestCache_NetworkIDs(t *testing.T) { + c := newCache() + require.Empty(t, c.networkIDs()) + + c.set(1, cacheEntry{url: "http://network-1"}) + c.set(0, cacheEntry{url: "http://network-0"}) + c.set(42, cacheEntry{url: "http://network-42"}) + + require.ElementsMatch(t, []uint32{0, 1, 42}, c.networkIDs()) + + // Overwriting an existing entry does not duplicate it + c.set(1, cacheEntry{url: "http://network-1-updated"}) + require.ElementsMatch(t, []uint32{0, 1, 42}, c.networkIDs()) +} + +// TestFinder_NetworkIDs verifies finder.NetworkIDs delegates to the cache, i.e. it reports +// exactly the networks GetURL would presently succeed for. +func TestFinder_NetworkIDs(t *testing.T) { + f := &finder{cache: newCache()} + require.Empty(t, f.NetworkIDs()) + + f.cache.set(1, cacheEntry{url: "http://network-1"}) + f.cache.set(7, cacheEntry{url: "http://network-7"}) + + ids := f.NetworkIDs() + require.ElementsMatch(t, []uint32{1, 7}, ids) + + for _, id := range ids { + urls, err := f.GetURL(id) + require.NoError(t, err) + require.NotEmpty(t, urls.BridgeURL) + } +} diff --git a/bridgeservicefinder/interfaces.go b/bridgeservicefinder/interfaces.go index cf94c58e2..846832dc3 100644 --- a/bridgeservicefinder/interfaces.go +++ b/bridgeservicefinder/interfaces.go @@ -68,6 +68,11 @@ type Finder interface { // ErrURLNotFound if nothing is cached. networkID follows the mapping documented in doc.go // (networkID == rollupID; network 0 is L1 and is only served if provided via Config.BridgeURLs). GetURL(networkID uint32) (NetworkURLs, error) + // NetworkIDs returns the networkIDs of every network currently resolved — i.e. every network + // GetURL would presently succeed for. Used by callers that need to enumerate every configured + // bridge service rather than query one network at a time (e.g. the bridge tracker's activity + // scanner). Order is unspecified. + NetworkIDs() []uint32 } // RollupManagerQuerier enumerates the rollups attached to a rollup manager and reads their data. diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go new file mode 100644 index 000000000..803bd5a5f --- /dev/null +++ b/bridgetracker/activity.go @@ -0,0 +1,148 @@ +package bridgetracker + +import ( + "context" + "fmt" + "sync" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum/common" +) + +// compile-time check: ActivityCache fulfils ActivityQuerier +var _ ActivityQuerier = (*ActivityCache)(nil) + +// ActivityCache implements domain.ActivityQuerier: for a given from_address it scans every +// configured bridge service (via ActivityBridgeScanner) and keeps a running per-address cache +// of the resulting bridges, so an already-settled bridge — claimed, with its claim record +// already fetched — is never rechecked again. Every other entry (new, still unclaimed, or +// claimed but not yet indexed by the destination bridge service) is rechecked on every call. +// +// Safe for concurrent use. +type ActivityCache struct { + scanner ActivityBridgeScanner + claims ActivityClaimChecker + supervised SupervisedStore + logger aggkitcommon.Logger + + mu sync.Mutex + byAddr map[common.Address]map[string]*domain.ActivityEntry // key: bridge.GlobalIndex.String() +} + +// NewActivityCache returns an ActivityCache resolving bridges through scanner, claim state +// through claims, and (when asked) tracker registration through supervised +func NewActivityCache( + scanner ActivityBridgeScanner, claims ActivityClaimChecker, supervised SupervisedStore, + logger aggkitcommon.Logger, +) *ActivityCache { + return &ActivityCache{ + scanner: scanner, + claims: claims, + supervised: supervised, + logger: logger, + byAddr: make(map[common.Address]map[string]*domain.ActivityEntry), + } +} + +// GetActivity implements domain.ActivityQuerier +func (a *ActivityCache) GetActivity( + ctx context.Context, fromAddress common.Address, includeTracking bool, +) ([]*domain.ActivityEntry, error) { + items, err := a.scanner.BridgesFrom(ctx, fromAddress) + if err != nil { + return nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) + } + + addrCache := a.addrCache(fromAddress) + + for _, item := range items { + key := item.GlobalIndex.String() + + a.mu.Lock() + existing := addrCache[key] + a.mu.Unlock() + + if existing != nil && settled(existing) { + continue + } + + entry := a.refresh(ctx, item, includeTracking) + + a.mu.Lock() + addrCache[key] = entry + a.mu.Unlock() + } + + a.mu.Lock() + defer a.mu.Unlock() + out := make([]*domain.ActivityEntry, 0, len(addrCache)) + for _, entry := range addrCache { + out = append(out, entry) + } + return out, nil +} + +// addrCache returns (creating if necessary) the per-address cache map for fromAddress +func (a *ActivityCache) addrCache(fromAddress common.Address) map[string]*domain.ActivityEntry { + a.mu.Lock() + defer a.mu.Unlock() + + addrCache, ok := a.byAddr[fromAddress] + if !ok { + addrCache = make(map[string]*domain.ActivityEntry) + a.byAddr[fromAddress] = addrCache + } + return addrCache +} + +// settled reports whether entry is done being rechecked: claimed, with the claim record +// already fetched. Anything else (unclaimed, the isClaimed() check itself having failed, or +// claimed but the destination bridge service has not indexed the claim yet) is re-verified on +// every GetActivity call +func settled(entry *domain.ActivityEntry) bool { + return entry.ClaimStatus == types.ClaimStatusClaimed && entry.Claim != nil +} + +// refresh (re)computes the claim/tracking state of a single bridge item: the on-chain +// isClaimed() call, then either the destination bridge service's claim record (once claimed) +// or — only if includeTracking — the tracker's current snapshot for the still-unclaimed tx. +// A failure at any step is logged and left for the next call to retry; it never fails the +// whole GetActivity call, since one bad network should not hide every other bridge found +func (a *ActivityCache) refresh( + ctx context.Context, item *bridgeservicetypes.BridgeResponse, includeTracking bool, +) *domain.ActivityEntry { + entry := &domain.ActivityEntry{Bridge: item} + + claimed, err := a.claims.IsClaimed(ctx, item) + if err != nil { + a.logger.Warnf("activity: checking claim state of bridge tx=%s (origin network=%d, deposit=%d): %v", + item.TxHash, item.OriginNetwork, item.DepositCount, err) + entry.ClaimStatus = types.ClaimStatusError + return entry + } + + if claimed { + entry.ClaimStatus = types.ClaimStatusClaimed + claim, err := a.claims.ClaimInfo(ctx, item) + if err != nil { + a.logger.Warnf("activity: fetching claim record of bridge tx=%s: %v", item.TxHash, err) + } + entry.Claim = claim + return entry + } + entry.ClaimStatus = types.ClaimStatusUnclaimed + + if includeTracking { + id := domain.TrackingID{NetworkID: item.OriginNetwork, TxHash: common.HexToHash(string(item.TxHash))} + tracking, err := a.supervised.Get(id, true) + if err != nil { + a.logger.Warnf("activity: registering bridge tx=%s with the tracker: %v", item.TxHash, err) + } else { + entry.Tracking = tracking + } + } + return entry +} diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go new file mode 100644 index 000000000..ccaada625 --- /dev/null +++ b/bridgetracker/activity_test.go @@ -0,0 +1,204 @@ +package bridgetracker + +import ( + "context" + "errors" + "math/big" + "testing" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +var testFromAddress = common.HexToAddress("0x1111111111111111111111111111111111111111") + +func testBridge(globalIndex int64) *bridgeservicetypes.BridgeResponse { + return &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 1, + DestinationNetwork: 2, + DepositCount: uint32(globalIndex), + GlobalIndex: big.NewInt(globalIndex), + TxHash: bridgeservicetypes.Hash("0xtx"), + } +} + +// fakeActivityScanner is a hand-rolled ActivityBridgeScanner for tests: bridges is returned on +// every BridgesFrom call, and calls records how many times it was invoked. +type fakeActivityScanner struct { + bridges []*bridgeservicetypes.BridgeResponse + err error + calls int +} + +func (f *fakeActivityScanner) BridgesFrom( + context.Context, common.Address, +) ([]*bridgeservicetypes.BridgeResponse, error) { + f.calls++ + return f.bridges, f.err +} + +// fakeActivityClaims is a hand-rolled ActivityClaimChecker for tests: isClaimed/claimInfo are +// consulted in FIFO order per call, one entry per expected IsClaimed/ClaimInfo invocation, so a +// test can assert exactly how many times each was called (and fail loudly if called more). +// isClaimedErrs, if non-nil, is consulted alongside isClaimed: a non-nil entry makes that call +// fail instead of returning the paired isClaimed value. +type fakeActivityClaims struct { + isClaimed []bool + isClaimedErrs []error + isClaimedCalls int + claimInfo []*bridgeservicetypes.ClaimResponse + claimInfoCalls int +} + +func (f *fakeActivityClaims) IsClaimed(context.Context, *bridgeservicetypes.BridgeResponse) (bool, error) { + i := f.isClaimedCalls + f.isClaimedCalls++ + if i < len(f.isClaimedErrs) && f.isClaimedErrs[i] != nil { + return false, f.isClaimedErrs[i] + } + return f.isClaimed[i], nil +} + +func (f *fakeActivityClaims) ClaimInfo( + context.Context, *bridgeservicetypes.BridgeResponse, +) (*bridgeservicetypes.ClaimResponse, error) { + claim := f.claimInfo[f.claimInfoCalls] + f.claimInfoCalls++ + return claim, nil +} + +func newTestActivityCache(scanner ActivityBridgeScanner, claims ActivityClaimChecker) *ActivityCache { + supervised := NewMemoryRegistry(10) + return NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test")) +} + +// TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall verifies an unclaimed bridge's claim +// state is re-verified on every GetActivity call, and that includeTracking=false never +// registers it with the tracker. +func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + + for range 2 { + entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + require.Nil(t, entries[0].Tracking) + } + require.Equal(t, 2, claims.isClaimedCalls) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_IncludeTrackingRegistersUnclaimedBridge verifies includeTracking=true +// registers a still-unclaimed bridge with the supervised store (register-only) and reports its +// snapshot. +func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{isClaimed: []bool{false}} + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, true) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.NotNil(t, entries[0].Tracking) + + wantID := domain.TrackingID{NetworkID: bridge.OriginNetwork, TxHash: common.HexToHash(string(bridge.TxHash))} + require.Equal(t, wantID, entries[0].Tracking.ID()) +} + +// TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked verifies a bridge that is claimed +// with its claim record already fetched is never rechecked on a later call. +func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + // only one IsClaimed/ClaimInfo entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) + + entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls) + require.Equal(t, 1, claims.claimInfoCalls) + require.Equal(t, 2, scanner.calls) // BridgesFrom is still called every time to find new bridges +} + +// TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried verifies a bridge reported as +// claimed on-chain, but whose claim record the destination bridge service has not indexed yet +// (ClaimInfo returns nil), is retried on the next call. +func TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{ + isClaimed: []bool{true, true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{nil, claim}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + + entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) +} + +// TestActivityCache_ScannerErrorFailsTheCall verifies a scanner failure fails GetActivity +// entirely. +func TestActivityCache_ScannerErrorFailsTheCall(t *testing.T) { + wantErr := errors.New("bridge service unreachable") + scanner := &fakeActivityScanner{err: wantErr} + cache := newTestActivityCache(scanner, &fakeActivityClaims{}) + + _, err := cache.GetActivity(t.Context(), testFromAddress, false) + require.ErrorIs(t, err, wantErr) +} + +// TestActivityCache_IsClaimedFailureReportsErrorStatus verifies a failed isClaimed() check +// (e.g. no bridge contract address configured for the destination network) is reported as +// ClaimStatusError — never silently as ClaimStatusUnclaimed — and is retried on the next call. +func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{ + isClaimed: []bool{false, false}, + isClaimedErrs: []error{errors.New("no bridge contract address configured for network 2"), nil}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + + // the error state is not settled: it is retried on the next call + entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Equal(t, 2, claims.isClaimedCalls) +} diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go new file mode 100644 index 000000000..5004c5b8e --- /dev/null +++ b/bridgetracker/api/activity_command.go @@ -0,0 +1,115 @@ +package api + +import ( + "net/http" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +// compile-time check: activityCommand fulfils the command interface +var _ command = (*activityCommand)(nil) + +// activityCommand answers GET /activity/from/{from_address}: it scans every configured bridge +// service for bridges sent by from_address and reports their claim (and, optionally, tracking) +// state +type activityCommand struct { + querier domain.ActivityQuerier +} + +// ActivityItem is one bridge found for the requested from_address. Bridge and Claim are the +// bridge service's own response shapes (see bridgeservice/types), reported exactly as-is +// rather than remapped into a bespoke model; BridgeNetworkID/ClaimNetworkID sit alongside them +// (not inside) since the caller needs to know which bridge service produced each one +type ActivityItem struct { + // Bridge is the raw bridge event, exactly as returned by the origin network's bridge + // service, unmodified + Bridge *bridgeservicetypes.BridgeResponse `json:"bridge"` + // BridgeNetworkID is the network whose bridge service reported Bridge (its origin network) + BridgeNetworkID uint32 `json:"bridge_network_id"` + // Claimed is the tri-state result of the destination bridge contract's isClaimed() call + // the last time it was checked: "false" (confirmed unclaimed), "true" (claimed), or + // "error" if the check itself failed (e.g. no bridge contract address configured for the + // destination network) — callers must not read "error" as "false" + Claimed string `json:"claimed"` + // ClaimNetworkID is the network whose bridge service reported Claim (the bridge's + // destination network); only present alongside Claim + ClaimNetworkID uint32 `json:"claim_network_id,omitempty"` + // Claim is the raw claim record, exactly as returned by the destination network's bridge + // service, unmodified, once Claimed is true and the indexer has recorded it + Claim *bridgeservicetypes.ClaimResponse `json:"claim,omitempty"` + // Tracking is the bridge tracker's current status for this bridge; only present when the + // request set includeTracking=true and the bridge is still unclaimed + Tracking *TrackingData `json:"tracking,omitempty"` +} + +// ActivityResponse is the body of GET /activity/from/{from_address} +type ActivityResponse struct { + // FromAddress is the address requested + FromAddress common.Address `json:"from_address"` + // Bridges holds every bridge found for FromAddress across every configured bridge service + Bridges []ActivityItem `json:"bridges"` +} + +// Execute implements command: it scans every configured bridge service for bridges sent by the +// from_address path parameter, and reports each one's claim state. Passing +// ?includeTracking=true additionally registers every still-unclaimed bridge found with the +// bridge tracker (same effect as calling GetTxStatus for it) and includes its current tracking +// snapshot. +// 200 OK unless: invalid from_address (ErrorData/400), or the scan itself failed (ErrorData/500) +// +// @Summary Get bridge activity by sender address +// @Description Scans every bridge service the tracker knows about for bridges sent by +// @Description from_address and reports each one's claim state, exactly as the bridge service +// @Description reported it. Results are cached: a bridge already known to be claimed, with its +// @Description claim record already fetched, is not rechecked on a later call. Passing +// @Description includeTracking=true additionally registers every still-unclaimed bridge with +// @Description the bridge tracker and includes its current tracking snapshot. +// @Tags bridge-tracker +// @Produce json +// @Param from_address path string true "Address that sent the bridges to look up" +// @Param includeTracking query bool false "Register still-unclaimed bridges with the tracker" +// @Success 200 {object} ActivityResponse +// @Failure 400 {object} types.ErrorData "Invalid from_address" +// @Failure 500 {object} types.ErrorData "Scanning the configured bridge services failed" +// @Router /activity/from/{from_address} [get] +func (cmd *activityCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) { + addrStr := c.Param(fromAddressParam) + if !common.IsHexAddress(addrStr) { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: "invalid from_address parameter"} + } + fromAddress := common.HexToAddress(addrStr) + includeTracking := c.Query(includeTrackingQueryParam) == "true" + + entries, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + + return http.StatusOK, ActivityResponse{FromAddress: fromAddress, Bridges: newActivityItems(entries)}, nil +} + +// newActivityItems builds the wire ActivityItems from the resolved activity entries +func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { + items := make([]ActivityItem, 0, len(entries)) + for _, e := range entries { + item := ActivityItem{ + Bridge: e.Bridge, + BridgeNetworkID: e.Bridge.OriginNetwork, + Claimed: e.ClaimStatus.String(), + } + if e.Claim != nil { + item.Claim = e.Claim + item.ClaimNetworkID = e.Bridge.DestinationNetwork + } + if e.Tracking != nil { + tracking := trackingDataFrom(e.Tracking) + item.Tracking = &tracking + } + items = append(items, item) + } + return items +} diff --git a/bridgetracker/api/api.go b/bridgetracker/api/api.go index 9828ead4d..8ccb4bc88 100644 --- a/bridgetracker/api/api.go +++ b/bridgetracker/api/api.go @@ -33,8 +33,14 @@ const ( // TrackerV1Prefix is the url prefix for the bridge tracker service TrackerV1Prefix = "/tracker/v1" - txHashParam = "tx_hash" - networkIDParam = "network_id" + txHashParam = "tx_hash" + networkIDParam = "network_id" + fromAddressParam = "from_address" + + // includeTrackingQueryParam, when set to "true", makes the activity endpoint additionally + // register every still-unclaimed bridge it finds with the bridge tracker (see + // activityCommand.Execute) + includeTrackingQueryParam = "includeTracking" decimalBase = 10 uint32BitSize = 32 @@ -48,21 +54,26 @@ type API struct { getTxStatusCmd *getTxStatusCommand healthCmd *healthCommand wsHandler *wsHandler + // activityCmd serves GET /activity/from/{from_address}; nil (when NewAPI is given a nil + // activity) leaves the route unregistered entirely — see RegisterRoutes + activityCmd *activityCommand } // NewAPI returns the tracker HTTP service serving the given supervised registry. // registerResolveTimeout is how long GetTxStatus waits, the first time a tx is registered, for // the tracking engine's immediate resolution attempt to produce an update before answering (see // getTxStatusCommand); <= 0 disables the wait. cors governs which origins may open the -// WebSocket endpoint (see wsHandler) +// WebSocket endpoint (see wsHandler). activity may be nil, in which case the +// GET /activity/from/{from_address} endpoint is not registered at all (see RegisterRoutes) func NewAPI( logger aggkitcommon.Logger, configSHA1 string, supervised domain.SupervisedRegistry, + activity domain.ActivityQuerier, registerResolveTimeout time.Duration, cors aggkitcommon.CORSConfig, ) *API { - return &API{ + api := &API{ getTxStatusCmd: &getTxStatusCommand{supervised: supervised, resolveTimeout: registerResolveTimeout}, healthCmd: &healthCommand{ // instanceID is a UUID generated at startup, exposed by the health endpoint to @@ -72,6 +83,10 @@ func NewAPI( }, wsHandler: newWSHandler(logger, supervised, cors), } + if activity != nil { + api.activityCmd = &activityCommand{querier: activity} + } + return api } // RegisterRoutes registers all bridge tracker routes on router. Route-level documentation @@ -85,6 +100,10 @@ func (a *API) RegisterRoutes(router gin.IRouter) { trackerGroup.GET("/network/:"+networkIDParam+"/tx/:"+txHashParam, func(c *gin.Context) { runCommand(c, a.getTxStatusCmd) }) trackerGroup.GET("/network/:"+networkIDParam+"/tx/:"+txHashParam+"/ws", a.wsHandler.TxStatusWSHandler) + if a.activityCmd != nil { + trackerGroup.GET("/activity/from/:"+fromAddressParam, + func(c *gin.Context) { runCommand(c, a.activityCmd) }) + } // Swagger docs endpoint trackerGroup.GET("/swagger/*any", ginswagger.WrapHandler(swaggerfiles.Handler)) diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index 66c3c5faf..be7913631 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -22,6 +22,53 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -127,6 +174,66 @@ const docTemplate = `{ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -294,16 +401,6 @@ const docTemplate = `{ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -314,8 +411,7 @@ const docTemplate = `{ 1000, 1000000, 1000000000, - 60000000000, - 3600000000000 + 60000000000 ], "x-enum-varnames": [ "minDuration", @@ -326,32 +422,217 @@ const docTemplate = `{ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour" + "Minute" ] } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 9d9ec95c0..43dc2af4d 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -15,6 +15,53 @@ }, "basePath": "/tracker/v1", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -120,6 +167,66 @@ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -287,16 +394,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -307,8 +404,7 @@ 1000, 1000000, 1000000000, - 60000000000, - 3600000000000 + 60000000000 ], "x-enum-varnames": [ "minDuration", @@ -319,32 +415,217 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour" + "Minute" ] } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index 90fb991ec..f269bfe7f 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -1,5 +1,56 @@ basePath: /tracker/v1 definitions: + api.ActivityItem: + properties: + bridge: + allOf: + - $ref: '#/definitions/types.BridgeResponse' + description: |- + Bridge is the raw bridge event, exactly as returned by the origin network's bridge + service, unmodified + bridge_network_id: + description: BridgeNetworkID is the network whose bridge service reported + Bridge (its origin network) + type: integer + claim: + allOf: + - $ref: '#/definitions/types.ClaimResponse' + description: |- + Claim is the raw claim record, exactly as returned by the destination network's bridge + service, unmodified, once Claimed is true and the indexer has recorded it + claim_network_id: + description: |- + ClaimNetworkID is the network whose bridge service reported Claim (the bridge's + destination network); only present alongside Claim + type: integer + claimed: + description: |- + Claimed is the tri-state result of the destination bridge contract's isClaimed() call + the last time it was checked: "false" (confirmed unclaimed), "true" (claimed), or + "error" if the check itself failed (e.g. no bridge contract address configured for the + destination network) — callers must not read "error" as "false" + type: string + tracking: + allOf: + - $ref: '#/definitions/api.TrackingData' + description: |- + Tracking is the bridge tracker's current status for this bridge; only present when the + request set includeTracking=true and the bridge is still unclaimed + type: object + api.ActivityResponse: + properties: + bridges: + description: Bridges holds every bridge found for FromAddress across every + configured bridge service + items: + $ref: '#/definitions/api.ActivityItem' + type: array + from_address: + description: FromAddress is the address requested + items: + type: integer + type: array + type: object api.BridgeEventData: properties: amount: @@ -152,16 +203,6 @@ definitions: - 1000000000 - 60000000000 - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - - 1 - - 1000 - - 1000000 - - 1000000000 - - 60000000000 - - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - 1 - 1000 - 1000000 @@ -173,7 +214,6 @@ definitions: - 1000000 - 1000000000 - 60000000000 - - 3600000000000 format: int64 type: integer x-enum-varnames: @@ -185,28 +225,176 @@ definitions: - Second - Minute - Hour - - minDuration - - maxDuration - Nanosecond - Microsecond - Millisecond - Second - Minute - Hour - - minDuration - - maxDuration - Nanosecond - Microsecond - Millisecond - Second - Minute - - Hour - - Nanosecond - - Microsecond - - Millisecond - - Second - - Minute - - Hour + type: object + types.BridgeResponse: + description: Detailed information about a bridge event + properties: + amount: + description: Amount of tokens being bridged + example: "1000000000000000000" + type: string + block_num: + description: Block number where the bridge event was recorded + example: 1234 + type: integer + block_pos: + description: Position of the bridge event within the block + example: 1 + type: integer + block_timestamp: + description: Timestamp of the block containing the bridge event + example: 1684500000 + type: integer + bridge_hash: + description: Unique hash representing the bridge event, often used as an identifier + example: 0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd + type: string + deposit_count: + description: Count of total deposits processed so far for the given token/address + example: 10 + type: integer + destination_address: + description: Address of the token receiver on the destination network + example: 0xdef4567890abcdef1234567890abcdef12345678 + type: string + destination_network: + description: ID of the network where the bridge transaction is destined + example: 42161 + type: integer + from_address: + description: |- + Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA. + May be null if bridge was synced with SyncFromInBridges=false + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + global_index: + description: Global index of the bridge event (consisted of mainnet flag, + rollup id and deposit count) + example: "4294967296" + type: string + leaf_type: + description: Type of leaf (bridge event type) used in the tree structure + example: 1 + type: integer + metadata: + description: Optional metadata attached to the bridge event + example: "0xdeadbeef" + type: string + origin_address: + description: Address of the token sender on the origin network + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + origin_network: + description: ID of the network where the bridge transaction originated + example: 10 + type: integer + to_address: + description: Address of the contract that was the recipient of the transaction. + This may differ from the bridge contract address. + example: 0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0 + type: string + tx_hash: + description: Hash of the transaction that included the bridge event + example: 0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + type: string + txn_sender: + description: Address of the transaction sender who initiated the bridge transaction + example: 0xabc1234567890abcdef1234567890abcdef12345 + type: string + type: object + types.ClaimResponse: + description: Detailed information about a claim event + properties: + amount: + description: Amount claimed + example: "1000000000000000000" + type: string + block_num: + description: Block number where the claim was processed + example: 1234 + type: integer + block_timestamp: + description: Timestamp of the block containing the claim + example: 1684500000 + type: integer + destination_address: + description: Address receiving the claim on the destination network + example: 0xdef4567890abcdef1234567890abcdef12345678 + type: string + destination_network: + description: Destination network ID where the claim was processed + example: 42161 + type: integer + from_address: + description: Address from which the claim originated + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + global_exit_root: + description: Global exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + global_index: + description: Global index of the claim + example: "1000000000000000000" + type: string + is_message: + description: IsMessage indicates whether this is a message claim (leaf type + 1) rather than an asset claim (leaf type 0) + example: false + type: boolean + mainnet_exit_root: + description: Mainnet exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + metadata: + description: Metadata associated with the claim + example: "0xdeadbeef" + type: string + origin_address: + description: Address initiating the claim on the origin network + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + origin_network: + description: Origin network ID where the claim was initiated + example: 10 + type: integer + proof_local_exit_root: + description: Proof local exit root associated with the claim (optional) + example: + - '[0x1' + - ' 0x2' + - ' 0x3...]' + items: + type: string + type: array + proof_rollup_exit_root: + description: Proof rollup exit root associated with the claim (optional) + example: + - '[0x4' + - ' 0x5' + - ' 0x6...]' + items: + type: string + type: array + rollup_exit_root: + description: Rollup exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + tx_hash: + description: Transaction hash associated with the claim + example: 0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + type: string type: object types.ErrorData: properties: @@ -303,6 +491,43 @@ info: title: Bridge Tracker API version: "1.0" paths: + /activity/from/{from_address}: + get: + description: |- + Scans every bridge service the tracker knows about for bridges sent by + from_address and reports each one's claim state, exactly as the bridge service + reported it. Results are cached: a bridge already known to be claimed, with its + claim record already fetched, is not rechecked on a later call. Passing + includeTracking=true additionally registers every still-unclaimed bridge with + the bridge tracker and includes its current tracking snapshot. + parameters: + - description: Address that sent the bridges to look up + in: path + name: from_address + required: true + type: string + - description: Register still-unclaimed bridges with the tracker + in: query + name: includeTracking + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ActivityResponse' + "400": + description: Invalid from_address + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Scanning the configured bridge services failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get bridge activity by sender address + tags: + - bridge-tracker /health: get: description: |- diff --git a/bridgetracker/bridgetracker.go b/bridgetracker/bridgetracker.go index 2d342e83c..3b6c60947 100644 --- a/bridgetracker/bridgetracker.go +++ b/bridgetracker/bridgetracker.go @@ -30,10 +30,18 @@ func New(cfg *Config) *BridgeTracker { supervised = NewMemoryRegistry(cfg.MaxTrackedBridges) } + // The activity endpoint is only registered when both driven ports are wired (see + // Config.ActivityScanner/ActivityClaims); a nil ActivityQuerier tells api.NewAPI to skip it + var activity ActivityQuerier + if cfg.ActivityScanner != nil && cfg.ActivityClaims != nil { + activity = NewActivityCache(cfg.ActivityScanner, cfg.ActivityClaims, supervised, cfg.Logger) + } + return &BridgeTracker{ logger: cfg.Logger, supervised: supervised, - api: api.NewAPI(cfg.Logger, cfg.ConfigSHA1, supervised, cfg.RegisterResolveTimeout.Duration, cfg.CORS), + api: api.NewAPI( + cfg.Logger, cfg.ConfigSHA1, supervised, activity, cfg.RegisterResolveTimeout.Duration, cfg.CORS), } } diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 84603c908..50f3b76d0 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/agglayer/aggkit" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/api" "github.com/agglayer/aggkit/bridgetracker/types" "github.com/agglayer/aggkit/log" @@ -329,3 +330,63 @@ func TestHealthHandlerNoSideEffects(t *testing.T) { defer reg.mu.RUnlock() require.Empty(t, reg.bridges) } + +// TestActivityHandlerNotRegisteredWithoutSources verifies the activity endpoint is absent +// (404) when Config.ActivityScanner/ActivityClaims are left nil, exactly like every tracker +// built before this endpoint existed +func TestActivityHandlerNotRegisteredWithoutSources(t *testing.T) { + _, router := newTestTracker(t) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusNotFound, resp.Code) +} + +// TestActivityHandlerInvalidAddress verifies an invalid from_address is rejected with 400 +func TestActivityHandlerInvalidAddress(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{}, + ActivityClaims: &fakeActivityClaims{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/not-an-address") + require.Equal(t, http.StatusBadRequest, resp.Code) +} + +// TestActivityHandlerHappyPath verifies the activity endpoint reports the bridges found by the +// wired sources, in the ActivityResponse wire shape +func TestActivityHandlerHappyPath(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{bridge}, + }, + ActivityClaims: &fakeActivityClaims{ + isClaimed: []bool{true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, testFromAddress, body.FromAddress) + require.Len(t, body.Bridges, 1) + require.Equal(t, "true", body.Bridges[0].Claimed) + require.Equal(t, bridge.OriginNetwork, body.Bridges[0].BridgeNetworkID) + require.Equal(t, claim.TxHash, body.Bridges[0].Claim.TxHash) + require.Equal(t, bridge.DestinationNetwork, body.Bridges[0].ClaimNetworkID) +} diff --git a/bridgetracker/config.go b/bridgetracker/config.go index 54db02863..bb886e687 100644 --- a/bridgetracker/config.go +++ b/bridgetracker/config.go @@ -116,6 +116,14 @@ type Config struct { // why WebSocket needs its own check instead of reusing the REST CORS headers). Wired // programmatically from REST.CORS by the binary, not read directly from [Tracker]. CORS aggkitcommon.CORSConfig `mapstructure:"-"` + + // ActivityScanner and ActivityClaims wire the optional GET /activity/from/{from_address} + // endpoint (see ActivityCache): ActivityScanner scans every configured bridge service for + // bridges sent by an address, ActivityClaims resolves each one's claim state. Both are + // wired programmatically by the binary (see sources.ActivitySource, which implements + // both); leaving either nil leaves the endpoint unregistered entirely. + ActivityScanner ActivityBridgeScanner `mapstructure:"-"` + ActivityClaims ActivityClaimChecker `mapstructure:"-"` } // Validate checks if the configuration is valid diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go new file mode 100644 index 000000000..8e56cb235 --- /dev/null +++ b/bridgetracker/domain/activity.go @@ -0,0 +1,58 @@ +package domain + +import ( + "context" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" +) + +// ActivityEntry is one bridge found for a from_address, as of the last time it was (re)checked +// (see ActivityQuerier). Bridge and Claim are stored exactly as the bridge service returned +// them — this feature is a cache over that data, not a reinterpretation of it (see +// bridgeservice/types.BridgeResponse/ClaimResponse) +type ActivityEntry struct { + // Bridge is the raw bridge event, as returned by the origin network's bridge service + Bridge *bridgeservicetypes.BridgeResponse + // ClaimStatus is the tri-state result of the destination bridge contract's isClaimed() + // call the last time it was checked: Unclaimed, Claimed, or Error if the check itself + // failed (e.g. no bridge contract address configured for the destination network) — a + // consumer must not read Error as "not claimed" + ClaimStatus types.ClaimStatus + // Claim is the raw claim record, as returned by the destination network's bridge + // service, once ClaimStatus is Claimed and the indexer has recorded it; nil until then + Claim *bridgeservicetypes.ClaimResponse + // Tracking is the bridge tracker's current snapshot of this bridge, only populated while + // it is still unclaimed and the caller asked for it (includeTracking); nil otherwise + Tracking *TrackingData +} + +// ActivityBridgeScanner is the driven port to the raw bridge-service data behind the +// GET /activity/from/{from_address} endpoint: it scans every bridge service the tracker knows +// about for bridges sent by fromAddress +type ActivityBridgeScanner interface { + // BridgesFrom returns every bridge whose sender is fromAddress, across every configured + // bridge service, exactly as each network's own bridge service reports it + BridgesFrom(ctx context.Context, fromAddress common.Address) ([]*bridgeservicetypes.BridgeResponse, error) +} + +// ActivityClaimChecker is the driven port to a bridge's claim state on its destination +// network: IsClaimed is the on-chain source of truth, ClaimInfo is the raw claim record the +// destination network's bridge service indexed for it once claimed +type ActivityClaimChecker interface { + // IsClaimed calls the destination bridge contract's isClaimed() for bridge + IsClaimed(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (bool, error) + // ClaimInfo returns the raw claim record for bridge from its destination network's + // bridge service, or nil if the indexer has not recorded it yet + ClaimInfo(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (*bridgeservicetypes.ClaimResponse, error) +} + +// ActivityQuerier is the driven port the GET /activity/from/{from_address} HTTP command +// depends on +type ActivityQuerier interface { + // GetActivity returns every bridge sent by fromAddress across every configured bridge + // service, enriched with its claim state; includeTracking additionally feeds every + // still-unclaimed bridge to the bridge tracker (see ActivityEntry.Tracking) + GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool) ([]*ActivityEntry, error) +} diff --git a/bridgetracker/mocks/mock_activity_bridge_scanner.go b/bridgetracker/mocks/mock_activity_bridge_scanner.go new file mode 100644 index 000000000..6c6612208 --- /dev/null +++ b/bridgetracker/mocks/mock_activity_bridge_scanner.go @@ -0,0 +1,99 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/bridgeservice/types" +) + +// ActivityBridgeScanner is an autogenerated mock type for the ActivityBridgeScanner type +type ActivityBridgeScanner struct { + mock.Mock +} + +type ActivityBridgeScanner_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityBridgeScanner) EXPECT() *ActivityBridgeScanner_Expecter { + return &ActivityBridgeScanner_Expecter{mock: &_m.Mock} +} + +// BridgesFrom provides a mock function with given fields: ctx, fromAddress +func (_m *ActivityBridgeScanner) BridgesFrom(ctx context.Context, fromAddress common.Address) ([]*types.BridgeResponse, error) { + ret := _m.Called(ctx, fromAddress) + + if len(ret) == 0 { + panic("no return value specified for BridgesFrom") + } + + var r0 []*types.BridgeResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address) ([]*types.BridgeResponse, error)); ok { + return rf(ctx, fromAddress) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address) []*types.BridgeResponse); ok { + r0 = rf(ctx, fromAddress) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*types.BridgeResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address) error); ok { + r1 = rf(ctx, fromAddress) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityBridgeScanner_BridgesFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BridgesFrom' +type ActivityBridgeScanner_BridgesFrom_Call struct { + *mock.Call +} + +// BridgesFrom is a helper method to define mock.On call +// - ctx context.Context +// - fromAddress common.Address +func (_e *ActivityBridgeScanner_Expecter) BridgesFrom(ctx interface{}, fromAddress interface{}) *ActivityBridgeScanner_BridgesFrom_Call { + return &ActivityBridgeScanner_BridgesFrom_Call{Call: _e.mock.On("BridgesFrom", ctx, fromAddress)} +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Run(run func(ctx context.Context, fromAddress common.Address)) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address)) + }) + return _c +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Return(_a0 []*types.BridgeResponse, _a1 error) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) RunAndReturn(run func(context.Context, common.Address) ([]*types.BridgeResponse, error)) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityBridgeScanner creates a new instance of ActivityBridgeScanner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityBridgeScanner(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityBridgeScanner { + mock := &ActivityBridgeScanner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/mocks/mock_activity_claim_checker.go b/bridgetracker/mocks/mock_activity_claim_checker.go new file mode 100644 index 000000000..0bd4ae170 --- /dev/null +++ b/bridgetracker/mocks/mock_activity_claim_checker.go @@ -0,0 +1,154 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/bridgeservice/types" +) + +// ActivityClaimChecker is an autogenerated mock type for the ActivityClaimChecker type +type ActivityClaimChecker struct { + mock.Mock +} + +type ActivityClaimChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityClaimChecker) EXPECT() *ActivityClaimChecker_Expecter { + return &ActivityClaimChecker_Expecter{mock: &_m.Mock} +} + +// ClaimInfo provides a mock function with given fields: ctx, bridge +func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *types.BridgeResponse) (*types.ClaimResponse, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for ClaimInfo") + } + + var r0 *types.ClaimResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) (*types.ClaimResponse, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) *types.ClaimResponse); ok { + r0 = rf(ctx, bridge) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.ClaimResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.BridgeResponse) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityClaimChecker_ClaimInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClaimInfo' +type ActivityClaimChecker_ClaimInfo_Call struct { + *mock.Call +} + +// ClaimInfo is a helper method to define mock.On call +// - ctx context.Context +// - bridge *types.BridgeResponse +func (_e *ActivityClaimChecker_Expecter) ClaimInfo(ctx interface{}, bridge interface{}) *ActivityClaimChecker_ClaimInfo_Call { + return &ActivityClaimChecker_ClaimInfo_Call{Call: _e.mock.On("ClaimInfo", ctx, bridge)} +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) Run(run func(ctx context.Context, bridge *types.BridgeResponse)) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.BridgeResponse)) + }) + return _c +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) Return(_a0 *types.ClaimResponse, _a1 error) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) RunAndReturn(run func(context.Context, *types.BridgeResponse) (*types.ClaimResponse, error)) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Return(run) + return _c +} + +// IsClaimed provides a mock function with given fields: ctx, bridge +func (_m *ActivityClaimChecker) IsClaimed(ctx context.Context, bridge *types.BridgeResponse) (bool, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for IsClaimed") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) (bool, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) bool); ok { + r0 = rf(ctx, bridge) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.BridgeResponse) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityClaimChecker_IsClaimed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsClaimed' +type ActivityClaimChecker_IsClaimed_Call struct { + *mock.Call +} + +// IsClaimed is a helper method to define mock.On call +// - ctx context.Context +// - bridge *types.BridgeResponse +func (_e *ActivityClaimChecker_Expecter) IsClaimed(ctx interface{}, bridge interface{}) *ActivityClaimChecker_IsClaimed_Call { + return &ActivityClaimChecker_IsClaimed_Call{Call: _e.mock.On("IsClaimed", ctx, bridge)} +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *types.BridgeResponse)) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.BridgeResponse)) + }) + return _c +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) Return(_a0 bool, _a1 error) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *types.BridgeResponse) (bool, error)) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityClaimChecker creates a new instance of ActivityClaimChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityClaimChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityClaimChecker { + mock := &ActivityClaimChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/mocks/mock_activity_querier.go b/bridgetracker/mocks/mock_activity_querier.go new file mode 100644 index 000000000..018b9afdf --- /dev/null +++ b/bridgetracker/mocks/mock_activity_querier.go @@ -0,0 +1,100 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + domain "github.com/agglayer/aggkit/bridgetracker/domain" + + mock "github.com/stretchr/testify/mock" +) + +// ActivityQuerier is an autogenerated mock type for the ActivityQuerier type +type ActivityQuerier struct { + mock.Mock +} + +type ActivityQuerier_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityQuerier) EXPECT() *ActivityQuerier_Expecter { + return &ActivityQuerier_Expecter{mock: &_m.Mock} +} + +// GetActivity provides a mock function with given fields: ctx, fromAddress, includeTracking +func (_m *ActivityQuerier) GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool) ([]*domain.ActivityEntry, error) { + ret := _m.Called(ctx, fromAddress, includeTracking) + + if len(ret) == 0 { + panic("no return value specified for GetActivity") + } + + var r0 []*domain.ActivityEntry + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool) ([]*domain.ActivityEntry, error)); ok { + return rf(ctx, fromAddress, includeTracking) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool) []*domain.ActivityEntry); ok { + r0 = rf(ctx, fromAddress, includeTracking) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain.ActivityEntry) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, bool) error); ok { + r1 = rf(ctx, fromAddress, includeTracking) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityQuerier_GetActivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActivity' +type ActivityQuerier_GetActivity_Call struct { + *mock.Call +} + +// GetActivity is a helper method to define mock.On call +// - ctx context.Context +// - fromAddress common.Address +// - includeTracking bool +func (_e *ActivityQuerier_Expecter) GetActivity(ctx interface{}, fromAddress interface{}, includeTracking interface{}) *ActivityQuerier_GetActivity_Call { + return &ActivityQuerier_GetActivity_Call{Call: _e.mock.On("GetActivity", ctx, fromAddress, includeTracking)} +} + +func (_c *ActivityQuerier_GetActivity_Call) Run(run func(ctx context.Context, fromAddress common.Address, includeTracking bool)) *ActivityQuerier_GetActivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(bool)) + }) + return _c +} + +func (_c *ActivityQuerier_GetActivity_Call) Return(_a0 []*domain.ActivityEntry, _a1 error) *ActivityQuerier_GetActivity_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityQuerier_GetActivity_Call) RunAndReturn(run func(context.Context, common.Address, bool) ([]*domain.ActivityEntry, error)) *ActivityQuerier_GetActivity_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityQuerier creates a new instance of ActivityQuerier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityQuerier(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityQuerier { + mock := &ActivityQuerier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/ports.go b/bridgetracker/ports.go index b08e30c16..ee7b5bc94 100644 --- a/bridgetracker/ports.go +++ b/bridgetracker/ports.go @@ -109,6 +109,20 @@ type ClaimSource interface { ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) } +// ActivityEntry is one bridge found for a from_address by the GET /activity/from/{from_address} +// endpoint, enriched with its claim/tracking state; see domain.ActivityEntry +type ActivityEntry = domain.ActivityEntry + +// ActivityBridgeScanner is the driven port to the raw bridge-service data behind the activity +// endpoint: every bridge sent by a given address, across every configured bridge service +type ActivityBridgeScanner = domain.ActivityBridgeScanner + +// ActivityClaimChecker is the driven port to a bridge's claim state on its destination network +type ActivityClaimChecker = domain.ActivityClaimChecker + +// ActivityQuerier is the driven port the activity endpoint depends on +type ActivityQuerier = domain.ActivityQuerier + // SettlementSource is the driven port to the L1 evidence a certificate's settlement produces: // the RollupManager/GlobalExitRoot events (VerifyBatchesTrustedAggregator, UpdateL1InfoTree[V2]) // emitted by the settlement tx itself diff --git a/bridgetracker/sources/activity.go b/bridgetracker/sources/activity.go new file mode 100644 index 000000000..26f241a96 --- /dev/null +++ b/bridgetracker/sources/activity.go @@ -0,0 +1,184 @@ +package sources + +import ( + "context" + "fmt" + "sync" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/agglayer/aggkit/bridgeservice/client" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// activityPageSize is the page size used to page through a network's GET /bridge/v1/bridges +// while scanning for a given from_address (see ActivitySource.BridgesFrom) +const activityPageSize = uint32(100) + +// NetworkLister widens NetworkURLResolver with network enumeration: it is the slice of +// bridgeservicefinder.Finder ActivitySource needs on top of the per-network URL lookup every +// other source already uses, so it knows which bridge services to scan for a given address +// without a fixed config list. bridgeservicefinder.Finder satisfies it. +type NetworkLister interface { + NetworkURLResolver + // NetworkIDs returns the networkIDs of every network currently resolved + NetworkIDs() []uint32 +} + +// claimChecker is the minimal bridge contract surface ActivitySource needs to check a bridge's +// on-chain claim state; *agglayerbridgel2.Agglayerbridgel2 satisfies it +type claimChecker interface { + IsClaimed(opts *bind.CallOpts, leafIndex uint32, sourceBridgeNetwork uint32) (bool, error) +} + +// ActivitySource implements bridgetracker.ActivityBridgeScanner and ActivityClaimChecker: it +// scans every network the finder currently knows about for bridges sent by a given address +// (via each network's own bridge service), and resolves a bridge's claim state on its +// destination network — isClaimed() on the destination bridge contract as the source of truth, +// then the destination bridge service's own claim record once claimed. +type ActivitySource struct { + services *bridgeServiceClients + finder NetworkLister + ethClients EthClientResolver + bridgeAddrs map[uint32]common.Address + // newContract builds the claim-checking contract binding for a destination network, + // injectable for tests. Defaults to agglayerbridgel2.NewAgglayerbridgel2 + newContract func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) + + mu sync.Mutex + contracts map[uint32]claimChecker // destination networkID -> bound contract, built lazily +} + +// NewActivitySource returns an ActivitySource resolving bridge services and JSON-RPC clients +// through finder/ethClients, and destination bridge contract addresses through bridgeAddrs (see +// Config.BridgeAddrs) — a destination network absent from bridgeAddrs cannot be claim-checked +// (IsClaimed errors for it, see claimCheckerFor) +func NewActivitySource( + finder NetworkLister, ethClients EthClientResolver, bridgeAddrs map[uint32]common.Address, +) *ActivitySource { + return &ActivitySource{ + services: newBridgeServiceClients(finder), + finder: finder, + ethClients: ethClients, + bridgeAddrs: bridgeAddrs, + newContract: func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) { + return agglayerbridgel2.NewAgglayerbridgel2(addr, c) + }, + contracts: make(map[uint32]claimChecker), + } +} + +// BridgesFrom implements bridgetracker.ActivityBridgeScanner: it queries every network's own +// bridge service GET /bridge/v1/bridges filtered by from_address, paging until a short page. A +// network that cannot be reached is logged and skipped rather than failing the whole scan, so +// one misbehaving bridge service does not hide every other network's activity. +func (s *ActivitySource) BridgesFrom( + ctx context.Context, fromAddress common.Address, +) ([]*bridgeservicetypes.BridgeResponse, error) { + addr := fromAddress.Hex() + + var all []*bridgeservicetypes.BridgeResponse + for _, networkID := range s.finder.NetworkIDs() { + svc, err := s.services.aggkitBridgeClientFor(networkID) + if err != nil { + return nil, fmt.Errorf("resolving bridge service client for network %d: %w", networkID, err) + } + + items, err := fetchAllBridgesFrom(ctx, svc, networkID, addr, activityPageSize) + if err != nil { + return nil, fmt.Errorf("fetching bridges from %s on network %d: %w", fromAddress, networkID, err) + } + all = append(all, items...) + } + return all, nil +} + +// fetchAllBridgesFrom pages through networkID's GET /bridge/v1/bridges filtered by fromAddress +// until a page shorter than pageSize is returned +func fetchAllBridgesFrom( + ctx context.Context, svc *client.Client, networkID uint32, fromAddress string, pageSize uint32, +) ([]*bridgeservicetypes.BridgeResponse, error) { + var out []*bridgeservicetypes.BridgeResponse + for page := uint32(1); ; page++ { + res, err := svc.GetBridges(ctx, client.GetBridgesParams{ + NetworkID: networkID, + FromAddress: &fromAddress, + PageNumber: &page, + PageSize: &pageSize, + }) + if err != nil { + return nil, err + } + out = append(out, res.Bridges...) + if uint32(len(res.Bridges)) < pageSize { + return out, nil + } + } +} + +// IsClaimed implements bridgetracker.ActivityClaimChecker: it calls isClaimed() on bridge's +// destination bridge contract +func (s *ActivitySource) IsClaimed(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (bool, error) { + contract, err := s.claimCheckerFor(ctx, bridge.DestinationNetwork) + if err != nil { + return false, err + } + return contract.IsClaimed(&bind.CallOpts{Context: ctx}, bridge.DepositCount, bridge.OriginNetwork) +} + +// ClaimInfo implements bridgetracker.ActivityClaimChecker: it asks bridge's destination +// network's bridge service for the claim record matching bridge's global index +func (s *ActivitySource) ClaimInfo( + ctx context.Context, bridge *bridgeservicetypes.BridgeResponse, +) (*bridgeservicetypes.ClaimResponse, error) { + svc, err := s.services.aggkitBridgeClientFor(bridge.DestinationNetwork) + if err != nil { + return nil, err + } + + res, err := svc.GetClaims(ctx, client.GetClaimsParams{ + NetworkID: bridge.DestinationNetwork, + GlobalIndex: bridge.GlobalIndex, + }) + if isNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("fetching claim of global index %s on network %d: %w", + bridge.GlobalIndex, bridge.DestinationNetwork, err) + } + if res.Count == 0 || len(res.Claims) == 0 { + return nil, nil + } + return res.Claims[0], nil +} + +// claimCheckerFor returns (building and caching if necessary) the claim-checking contract +// binding for the given destination network +func (s *ActivitySource) claimCheckerFor(ctx context.Context, networkID uint32) (claimChecker, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if c, ok := s.contracts[networkID]; ok { + return c, nil + } + + addr, ok := s.bridgeAddrs[networkID] + if !ok { + return nil, fmt.Errorf("no bridge contract address configured for network %d (see [Tracker].BridgeAddrs)", + networkID) + } + rpcClient, err := s.ethClients.RPCClientFor(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving JSON-RPC client for network %d: %w", networkID, err) + } + contract, err := s.newContract(addr, rpcClient) + if err != nil { + return nil, fmt.Errorf("binding bridge contract %s on network %d: %w", addr, networkID, err) + } + + s.contracts[networkID] = contract + return contract, nil +} diff --git a/bridgetracker/sources/activity_test.go b/bridgetracker/sources/activity_test.go new file mode 100644 index 000000000..4ae99f128 --- /dev/null +++ b/bridgetracker/sources/activity_test.go @@ -0,0 +1,236 @@ +package sources + +import ( + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgeservicefinder" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const testFromAddress = "0x1111111111111111111111111111111111111111" + +// fakeActivityBridgeService emulates the bridge-service endpoints ActivitySource consumes: +// GET /bridge/v1/bridges (paginated, filtered by network_id/from_address) and +// GET /bridge/v1/claims (filtered by network_id/global_index). +type fakeActivityBridgeService struct { + // bridgesByNetwork holds every bridge served for a network, in page order + bridgesByNetwork map[uint32][]*bridgeservicetypes.BridgeResponse + // claimsByGlobalIndex holds the claim served for a given global index (decimal string), if any + claimsByGlobalIndex map[string]*bridgeservicetypes.ClaimResponse +} + +func (f *fakeActivityBridgeService) start(t *testing.T) string { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/bridge/v1/bridges", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + networkID, err := strconv.ParseUint(q.Get("network_id"), 10, 32) + require.NoError(t, err) + pageNumber, err := strconv.Atoi(q.Get("page_number")) + require.NoError(t, err) + pageSize, err := strconv.Atoi(q.Get("page_size")) + require.NoError(t, err) + + var matching []*bridgeservicetypes.BridgeResponse + for _, b := range f.bridgesByNetwork[uint32(networkID)] { + if from := q.Get("from_address"); from != "" && (b.FromAddress == nil || string(*b.FromAddress) != from) { + continue + } + matching = append(matching, b) + } + + start := (pageNumber - 1) * pageSize + end := min(start+pageSize, len(matching)) + if start > len(matching) { + start = len(matching) + } + page := matching[start:end] + + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.BridgesResult{ + Bridges: page, Count: len(matching), + })) + }) + mux.HandleFunc("/bridge/v1/claims", func(w http.ResponseWriter, r *http.Request) { + globalIndex := r.URL.Query().Get("global_index") + claim, ok := f.claimsByGlobalIndex[globalIndex] + if !ok { + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.ClaimsResult{Count: 0})) + return + } + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.ClaimsResult{ + Claims: []*bridgeservicetypes.ClaimResponse{claim}, Count: 1, + })) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server.URL +} + +// fakeNetworkLister is a fixed NetworkLister for tests: every networkID resolves to the same +// bridge service base URL +type fakeNetworkLister struct { + networkIDs []uint32 + url string +} + +func (f fakeNetworkLister) GetURL(uint32) (bridgeservicefinder.NetworkURLs, error) { + return bridgeservicefinder.NetworkURLs{BridgeURL: f.url}, nil +} + +func (f fakeNetworkLister) NetworkIDs() []uint32 { return f.networkIDs } + +func bridgeResponse(networkID, destNetwork, depositCount uint32, from string, globalIndex int64) *bridgeservicetypes.BridgeResponse { + fromAddr := bridgeservicetypes.Address(from) + return &bridgeservicetypes.BridgeResponse{ + OriginNetwork: networkID, + DestinationNetwork: destNetwork, + DepositCount: depositCount, + FromAddress: &fromAddr, + GlobalIndex: big.NewInt(globalIndex), + TxHash: bridgeservicetypes.Hash(fmt.Sprintf("0x%d", globalIndex)), + } +} + +// TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork verifies BridgesFrom pages +// through each network until a short page, scans every network the lister reports, and filters +// by from_address. +func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) { + other := "0x2222222222222222222222222222222222222222" + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 0, testFromAddress, 1), + bridgeResponse(1, 2, 1, testFromAddress, 2), + bridgeResponse(1, 2, 2, testFromAddress, 3), + bridgeResponse(1, 2, 3, other, 4), // different sender, must be filtered out + }, + 2: { + bridgeResponse(2, 1, 0, testFromAddress, 5), + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1, 2}, url: url} + + source := NewActivitySource(lister, nil, nil) + + items, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress)) + require.NoError(t, err) + require.Len(t, items, 4) + + globalIndexes := make([]int64, 0, len(items)) + for _, item := range items { + globalIndexes = append(globalIndexes, item.GlobalIndex.Int64()) + } + require.ElementsMatch(t, []int64{1, 2, 3, 5}, globalIndexes) +} + +// TestFetchAllBridgesFrom_Pagination exercises the pagination loop directly with a small page +// size, so a short page (fewer results than requested) stops the loop. +func TestFetchAllBridgesFrom_Pagination(t *testing.T) { + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 0, testFromAddress, 1), + bridgeResponse(1, 2, 1, testFromAddress, 2), + bridgeResponse(1, 2, 2, testFromAddress, 3), + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} + source := NewActivitySource(lister, nil, nil) + client, err := source.services.aggkitBridgeClientFor(1) + require.NoError(t, err) + + items, err := fetchAllBridgesFrom(t.Context(), client, 1, testFromAddress, 2) + require.NoError(t, err) + require.Len(t, items, 3) +} + +// TestActivitySource_IsClaimed_NoBridgeAddrConfigured verifies IsClaimed errors clearly when +// the destination network has no bridge contract address configured. +func TestActivitySource_IsClaimed_NoBridgeAddrConfigured(t *testing.T) { + source := NewActivitySource(fakeNetworkLister{}, StaticClients{}, map[uint32]common.Address{}) + + bridge := bridgeResponse(1, 2, 3, testFromAddress, 1) + _, err := source.IsClaimed(t.Context(), bridge) + require.ErrorContains(t, err, "no bridge contract address configured for network 2") +} + +// TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork verifies IsClaimed +// binds the destination network's contract and calls isClaimed(depositCount, originNetwork), and +// that the binding is cached across calls. +func TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork(t *testing.T) { + destAddr := common.HexToAddress("0xdead") + client := StaticClients{2: nil} + + stub := &stubClaimChecker{claimed: true} + buildCalls := 0 + source := NewActivitySource(fakeNetworkLister{}, client, map[uint32]common.Address{2: destAddr}) + source.newContract = func(addr common.Address, _ aggkittypes.BaseEthereumClienter) (claimChecker, error) { + buildCalls++ + require.Equal(t, destAddr, addr) + return stub, nil + } + + bridge := bridgeResponse(5, 2, 9, testFromAddress, 1) + claimed, err := source.IsClaimed(t.Context(), bridge) + require.NoError(t, err) + require.True(t, claimed) + require.Equal(t, uint32(9), stub.lastLeafIndex) + require.Equal(t, uint32(5), stub.lastSourceNetwork) + + // A second call for the same destination network reuses the cached binding + _, err = source.IsClaimed(t.Context(), bridge) + require.NoError(t, err) + require.Equal(t, 1, buildCalls) +} + +// stubClaimChecker is an injectable claimChecker for tests +type stubClaimChecker struct { + claimed bool + err error + lastLeafIndex uint32 + lastSourceNetwork uint32 +} + +func (s *stubClaimChecker) IsClaimed(_ *bind.CallOpts, leafIndex, sourceBridgeNetwork uint32) (bool, error) { + s.lastLeafIndex = leafIndex + s.lastSourceNetwork = sourceBridgeNetwork + return s.claimed, s.err +} + +// TestActivitySource_ClaimInfo verifies ClaimInfo fetches the raw claim record by global index, +// and returns nil (not an error) when the destination bridge service has not indexed it yet. +func TestActivitySource_ClaimInfo(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx", GlobalIndex: "1"} + svc := &fakeActivityBridgeService{ + claimsByGlobalIndex: map[string]*bridgeservicetypes.ClaimResponse{"1": claim}, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{2}, url: url} + source := NewActivitySource(lister, nil, nil) + + found := bridgeResponse(1, 2, 0, testFromAddress, 1) + got, err := source.ClaimInfo(t.Context(), found) + require.NoError(t, err) + require.Equal(t, claim, got) + + notIndexedYet := bridgeResponse(1, 2, 0, testFromAddress, 999) + got, err = source.ClaimInfo(t.Context(), notIndexedYet) + require.NoError(t, err) + require.Nil(t, got) +} diff --git a/bridgetracker/types/claim_status.go b/bridgetracker/types/claim_status.go new file mode 100644 index 000000000..34f4a26c0 --- /dev/null +++ b/bridgetracker/types/claim_status.go @@ -0,0 +1,36 @@ +package types + +import "fmt" + +// ClaimStatus is the tri-state result of checking a bridge's on-chain claim state (see +// domain.ActivityEntry / the GET /activity/from/{from_address} endpoint): unlike a plain bool, +// it distinguishes "confirmed unclaimed" from "the check itself failed" (e.g. no bridge +// contract address configured for the destination network, or an RPC failure) — a caller must +// not read ClaimStatusError as "not claimed". +type ClaimStatus int + +const ( + // ClaimStatusUnclaimed the destination bridge contract's isClaimed() call succeeded and + // reported the bridge as not yet claimed + ClaimStatusUnclaimed ClaimStatus = iota + // ClaimStatusClaimed the destination bridge contract's isClaimed() call succeeded and + // reported the bridge as claimed + ClaimStatusClaimed + // ClaimStatusError the isClaimed() check itself failed; the claim state is unknown and + // will be retried on the next call + ClaimStatusError +) + +var claimStatusNames = map[ClaimStatus]string{ + ClaimStatusUnclaimed: "false", + ClaimStatusClaimed: "true", + ClaimStatusError: "error", +} + +// String representation of the enum: "false", "true" or "error" +func (s ClaimStatus) String() string { + if name, ok := claimStatusNames[s]; ok { + return name + } + return fmt.Sprintf("Unknown(%d)", int(s)) +} diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 9d9ec95c0..43dc2af4d 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -15,6 +15,53 @@ }, "basePath": "/tracker/v1", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -120,6 +167,66 @@ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -287,16 +394,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -307,8 +404,7 @@ 1000, 1000000, 1000000000, - 60000000000, - 3600000000000 + 60000000000 ], "x-enum-varnames": [ "minDuration", @@ -319,32 +415,217 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour" + "Minute" ] } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 2e27b5d44..ce7f9b0de 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -166,7 +166,6 @@ func runTracker( // served alongside (see aggkitcommon.CORSConfig.OriginAllowed for why it can't just reuse // the REST CORS headers). trackerCfg.CORS = cfg.REST.CORS - tracker := bridgetracker.New(&trackerCfg) if err := trackerCfg.AgglayerClient.Validate(); err != nil { log.Fatalf("invalid agglayer client config: %v", err) @@ -189,6 +188,15 @@ func runTracker( gerSource := sources.NewGERSource(finder, rpcClients, trackerCfg.L1GlobalExitRootAddress, trackerCfg.L1BlockFinality, log.WithFields("module", "bridgetracker-gersource")) + // GET /activity/from/{from_address} scans every network the finder knows about (via + // finder.NetworkIDs) for bridges sent by an address, and resolves their claim state through + // the same per-network JSON-RPC clients and BridgeAddrs used above + activitySource := sources.NewActivitySource(finder, rpcClients, trackerCfg.BridgeAddrs) + trackerCfg.ActivityScanner = activitySource + trackerCfg.ActivityClaims = activitySource + + tracker := bridgetracker.New(&trackerCfg) + engine, err := bridgetracker.NewEngine( bridgetracker.EngineConfig{ RetentionPeriod: trackerCfg.RetentionPeriod.Duration, From 919ae4f44cf9d1aadc387807fd25109ad6faeb6d Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:40:05 +0200 Subject: [PATCH 07/16] =?UTF-8?q?feat(bridgetracker):=20activity=20endpoin?= =?UTF-8?q?t=20improvements=20=E2=80=94=20filters,=20network=20IDs,=20auto?= =?UTF-8?q?=20bridge=20address,=20incremental=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the GET /activity/from/{from_address} endpoint (3712ed8a) with: - filterBridges query param (all|claimed|pending|error, default all): lets a caller ask for only claimed, only pending, or only errored bridges. Requesting pending/error skips fetching a claimed bridge's claim record (it would be filtered out anyway) — the entry simply stays unsettled and is fetched normally once a filter that needs it is used. - claimed becomes a tri-state string ("false"/"true"/"error") instead of a bool, via types.ClaimStatus, so a failed isClaimed() check (e.g. no bridge contract address configured) is never confused with "not claimed"; the failure message is reported under errors["claim"]. - bridge_network_id / claim_network_id sit alongside the raw bridge/claim payloads (kept byte-for-byte as the bridge service returned them) instead of wrapping them, so callers know which bridge service produced each one without altering the response shape. - bridgeservicefinder.Finder gains BridgeAddress(ctx, networkID): defaults to the rollup manager's own on-chain BridgeAddress() (an immutable constructor parameter, resolved once and cached forever), overridable per network via the new BridgeServiceFinder.BridgeAddress config map — and a BridgeAddress[0] override doubles as the default for every network without its own entry. ActivitySource now resolves destination bridge contracts through this instead of a manually maintained address map. - ActivityCache no longer re-scans every page of every network on every call: ActivityBridgeScanner.BridgesFrom takes the caller's already-known global indexes and each network's scan stops at the first already-known bridge, relying on the bridge service's own newest-first order. Once a bridge is confirmed claimed, isClaimed() is never asked again for it (only its claim record may still need fetching); once a claim record is fetched, it is cached for good. A from_address idle for Config.ActivityIdleTimeout (default 30m, mirroring IdleTimeout) is forgotten entirely on the next request, freeing everything cached for it — swept lazily on access rather than a dedicated ticker/goroutine. Co-Authored-By: Claude Sonnet 5 --- autoclaim/proof/leaf_proof_refresher_test.go | 4 + autoclaim/runtime/runtime.go | 6 + autoclaim/runtime/runtime_test.go | 4 + bridgeservicefinder/bridge_address_test.go | 123 ++++++++++ bridgeservicefinder/bridgeservicefinder.go | 45 ++++ bridgeservicefinder/config.go | 7 + bridgeservicefinder/interfaces.go | 13 + .../mocks/mock_rollup_manager_querier.go | 60 +++++ bridgetracker/activity.go | 226 +++++++++++++---- bridgetracker/activity_test.go | 228 ++++++++++++++++-- bridgetracker/api/activity_command.go | 25 +- bridgetracker/api/api.go | 4 + bridgetracker/api/docs/docs.go | 70 +++++- bridgetracker/api/docs/swagger.json | 70 +++++- bridgetracker/api/docs/swagger.yaml | 65 ++++- bridgetracker/bridgetracker.go | 3 +- bridgetracker/bridgetracker_test.go | 80 ++++++ bridgetracker/config.go | 7 + bridgetracker/domain/activity.go | 28 ++- bridgetracker/sources/activity.go | 75 +++--- bridgetracker/sources/activity_test.go | 68 +++++- bridgetracker/types/activity_filter.go | 53 ++++ bridgetracker/types/activity_filter_test.go | 42 ++++ .../swagger/bridge_tracker/swagger.json | 70 +++++- proxy/cmd/run.go | 5 +- proxy/config/default.go | 7 + 26 files changed, 1240 insertions(+), 148 deletions(-) create mode 100644 bridgeservicefinder/bridge_address_test.go create mode 100644 bridgetracker/types/activity_filter.go create mode 100644 bridgetracker/types/activity_filter_test.go diff --git a/autoclaim/proof/leaf_proof_refresher_test.go b/autoclaim/proof/leaf_proof_refresher_test.go index aa64ae582..e34adf17d 100644 --- a/autoclaim/proof/leaf_proof_refresher_test.go +++ b/autoclaim/proof/leaf_proof_refresher_test.go @@ -44,6 +44,10 @@ func (f *fakeURLResolver) NetworkIDs() []uint32 { return ids } +func (f *fakeURLResolver) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, nil +} + // fakeClaimProofClient implements claimProofClient for tests, keyed by base URL. type fakeClaimProofClient struct { baseURL string diff --git a/autoclaim/runtime/runtime.go b/autoclaim/runtime/runtime.go index 21bc857bc..ff35162d2 100644 --- a/autoclaim/runtime/runtime.go +++ b/autoclaim/runtime/runtime.go @@ -538,6 +538,12 @@ func (noopBridgeServiceFinder) GetURL(networkID uint32) (bridgeservicefinder.Net func (noopBridgeServiceFinder) NetworkIDs() []uint32 { return nil } +func (noopBridgeServiceFinder) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + return common.Address{}, fmt.Errorf( + "autoclaim bridge service finder is not configured (AutoClaim.L2ToLxBridgeDetector.Enabled=false): network %d", + networkID) +} + // startRuntimeComponents launches the goroutines for tx managers, claimers, and the bridge detector. func startRuntimeComponents( ctx context.Context, diff --git a/autoclaim/runtime/runtime_test.go b/autoclaim/runtime/runtime_test.go index 9972baadb..7dfc88349 100644 --- a/autoclaim/runtime/runtime_test.go +++ b/autoclaim/runtime/runtime_test.go @@ -292,6 +292,10 @@ func (fakeBridgeServiceFinder) GetURL(uint32) (bridgeservicefinder.NetworkURLs, func (fakeBridgeServiceFinder) NetworkIDs() []uint32 { return nil } +func (fakeBridgeServiceFinder) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, nil +} + func withL2ToLxEnabled(cfg autoclaimcfg.Config) autoclaimcfg.Config { cfg.L2ToLxBridgeDetector = autoclaimcfg.L2ToLxBridgeDetector{ Enabled: true, diff --git a/bridgeservicefinder/bridge_address_test.go b/bridgeservicefinder/bridge_address_test.go new file mode 100644 index 000000000..407dce0fa --- /dev/null +++ b/bridgeservicefinder/bridge_address_test.go @@ -0,0 +1,123 @@ +package bridgeservicefinder + +import ( + "errors" + "testing" + + "github.com/agglayer/aggkit/bridgeservicefinder/mocks" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newTestFinderForBridgeAddress builds a *finder (not the Finder interface) with rm as its +// RollupManagerQuerier, so tests can call BridgeAddress directly without going through Start +// (BridgeAddress is resolved lazily, on first use, independently of the cache built by Start). +func newTestFinderForBridgeAddress(t *testing.T, cfg Config, rm RollupManagerQuerier) *finder { + t.Helper() + + f, err := New(cfg, Options{RollupManager: rm, LogFilterer: mocks.NewLogFilterer(t), Logger: testLogger()}) + require.NoError(t, err) + concrete, ok := f.(*finder) + require.True(t, ok) + return concrete +} + +// TestBridgeAddress_DefaultsToRollupManagerBridgeAddress verifies a network absent from +// Config.BridgeAddress resolves to the rollup manager's own on-chain BridgeAddress(). +func TestBridgeAddress_DefaultsToRollupManagerBridgeAddress(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + got, err := f.BridgeAddress(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, wantAddr, got) +} + +// TestBridgeAddress_CachesTheOnChainDefaultAcrossNetworksAndCalls verifies the on-chain +// BridgeAddress() call happens at most once, regardless of how many networks or calls ask for +// the default — it is an immutable constructor parameter, safe to cache forever. +func TestBridgeAddress_CachesTheOnChainDefaultAcrossNetworksAndCalls(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() // .Once(): a second call fails the test + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + for _, networkID := range []uint32{1, 2, 1} { + got, err := f.BridgeAddress(t.Context(), networkID) + require.NoError(t, err) + require.Equal(t, wantAddr, got) + } +} + +// TestBridgeAddress_OverridePrecedesTheOnChainDefault verifies a networkID present in +// Config.BridgeAddress is served verbatim, without ever consulting the rollup manager. +func TestBridgeAddress_OverridePrecedesTheOnChainDefault(t *testing.T) { + overrideAddr := common.HexToAddress("0xdeaf") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{BridgeAddress: map[uint32]common.Address{63: overrideAddr}}, rm) + + got, err := f.BridgeAddress(t.Context(), 63) + require.NoError(t, err) + require.Equal(t, overrideAddr, got) +} + +// TestBridgeAddress_Network0OverrideIsDefaultForOtherNetworks verifies a Config.BridgeAddress[0] +// override doubles as the default for a network with no override of its own, taking precedence +// over the on-chain rollup manager BridgeAddress() (which must never be consulted in this case). +func TestBridgeAddress_Network0OverrideIsDefaultForOtherNetworks(t *testing.T) { + network0Addr := common.HexToAddress("0xcafe") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{BridgeAddress: map[uint32]common.Address{0: network0Addr}}, rm) + + for _, networkID := range []uint32{0, 5, 82} { + got, err := f.BridgeAddress(t.Context(), networkID) + require.NoError(t, err) + require.Equal(t, network0Addr, got) + } +} + +// TestBridgeAddress_PerNetworkOverridePrecedesNetwork0Default verifies a network's own override +// wins over Config.BridgeAddress[0], even when both are configured. +func TestBridgeAddress_PerNetworkOverridePrecedesNetwork0Default(t *testing.T) { + network0Addr := common.HexToAddress("0xcafe") + network63Addr := common.HexToAddress("0xdeaf") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{ + BridgeAddress: map[uint32]common.Address{0: network0Addr, 63: network63Addr}, + }, rm) + + got, err := f.BridgeAddress(t.Context(), 63) + require.NoError(t, err) + require.Equal(t, network63Addr, got) + + got, err = f.BridgeAddress(t.Context(), 84) + require.NoError(t, err) + require.Equal(t, network0Addr, got) +} + +// TestBridgeAddress_OnChainFailureIsNotCached verifies a failed on-chain read is not cached: the +// next call retries instead of repeating the same error forever. +func TestBridgeAddress_OnChainFailureIsNotCached(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + wantErr := errors.New("rpc unavailable") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(common.Address{}, wantErr).Once() + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + _, err := f.BridgeAddress(t.Context(), 1) + require.ErrorIs(t, err, wantErr) + + got, err := f.BridgeAddress(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, wantAddr, got) +} diff --git a/bridgeservicefinder/bridgeservicefinder.go b/bridgeservicefinder/bridgeservicefinder.go index 86914482a..6db8e05b4 100644 --- a/bridgeservicefinder/bridgeservicefinder.go +++ b/bridgeservicefinder/bridgeservicefinder.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sync" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" aggkitcommon "github.com/agglayer/aggkit/common" @@ -74,6 +75,13 @@ type finder struct { // skipped entirely during enumeration (see buildInitialCache) and live discovery (see listener's // discoverRollup). ignoreNetworkIDs map[uint32]struct{} + + // bridgeAddrMu guards defaultBridgeAddr + bridgeAddrMu sync.Mutex + // defaultBridgeAddr caches the rollup manager's own BridgeAddress() once resolved (see + // BridgeAddress): nil until the first call needs it. It is an immutable constructor parameter of + // the rollup manager, so once resolved it is cached forever — never re-read or invalidated. + defaultBridgeAddr *common.Address } // buildIgnoreSet turns Config.IgnoreNetworkIDs into a set for O(1) membership checks. @@ -385,3 +393,40 @@ func (f *finder) GetURL(networkID uint32) (NetworkURLs, error) { func (f *finder) NetworkIDs() []uint32 { return f.cache.networkIDs() } + +// BridgeAddress returns the bridge contract address for networkID, in priority order: +// 1. Config.BridgeAddress[networkID], if set. +// 2. Config.BridgeAddress[0], if set — network 0's override doubles as the default for every +// other network that has none of its own, since it is typically the shared L1 bridge address. +// 3. The rollup manager's own on-chain BridgeAddress(), resolved once and cached forever (see +// defaultBridgeAddress) — only reached when neither override above is configured. +func (f *finder) BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) { + if addr, ok := f.cfg.BridgeAddress[networkID]; ok { + return addr, nil + } + if addr, ok := f.cfg.BridgeAddress[0]; ok { + return addr, nil + } + return f.defaultBridgeAddress(ctx) +} + +// defaultBridgeAddress returns the rollup manager's own BridgeAddress(), resolving it on chain the +// first time it is needed and caching it forever after: it is an immutable constructor parameter +// of the rollup manager, so it can never change once deployed. A transient failure (e.g. a +// transport error) is not cached, so the next call retries the on-chain read. +func (f *finder) defaultBridgeAddress(ctx context.Context) (common.Address, error) { + f.bridgeAddrMu.Lock() + defer f.bridgeAddrMu.Unlock() + + if f.defaultBridgeAddr != nil { + return *f.defaultBridgeAddr, nil + } + + addr, err := f.rollupManager.BridgeAddress(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, fmt.Errorf("reading rollup manager's bridge address: %w", err) + } + + f.defaultBridgeAddr = &addr + return addr, nil +} diff --git a/bridgeservicefinder/config.go b/bridgeservicefinder/config.go index b0597e5dd..12bf805bf 100644 --- a/bridgeservicefinder/config.go +++ b/bridgeservicefinder/config.go @@ -58,6 +58,13 @@ type Config struct { // absent from this map get their JSON-RPC endpoint from the rollup's trustedSequencerURL. RPCURLs map[uint32]string `mapstructure:"RPCURLs"` + // BridgeAddress is the static override map from networkID to bridge contract address, consulted + // by Finder.BridgeAddress in priority order: BridgeAddress[networkID], then BridgeAddress[0] + // (which doubles as the default for every network without its own entry — typically the shared + // L1 bridge address), then finally the rollup manager's own on-chain BridgeAddress() if neither + // is set. Only networks whose bridge contract differs from that default need their own entry. + BridgeAddress map[uint32]common.Address `mapstructure:"BridgeAddress"` + // BlockFinality is the finality level used to bound the upper block of each event scan, so the // finder does not react to logs that may still be reorged away. See aggkittypes.BlockNumberFinality. BlockFinality aggkittypes.BlockNumberFinality `jsonschema:"enum=PendingBlock,enum=LatestBlock,enum=SafeBlock,enum=FinalizedBlock,enum=EarliestBlock" mapstructure:"BlockFinality"` //nolint:lll diff --git a/bridgeservicefinder/interfaces.go b/bridgeservicefinder/interfaces.go index 846832dc3..3e4414099 100644 --- a/bridgeservicefinder/interfaces.go +++ b/bridgeservicefinder/interfaces.go @@ -73,6 +73,16 @@ type Finder interface { // bridge service rather than query one network at a time (e.g. the bridge tracker's activity // scanner). Order is unspecified. NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID, in priority order: + // Config.BridgeAddress[networkID] if set; else Config.BridgeAddress[0] if set (network 0's + // override doubles as the default for every network without its own, since it is typically the + // shared L1 bridge address); else the rollup manager's own on-chain BridgeAddress() — resolved + // once and cached forever, since it is an immutable constructor parameter of the rollup manager. + // A network whose bridge contract differs from that default needs its own + // Config.BridgeAddress override. Returns an error only when no override applies and the + // on-chain default could not be resolved (e.g. a transport failure) — such a failure is not + // cached, so the next call retries. + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) } // RollupManagerQuerier enumerates the rollups attached to a rollup manager and reads their data. @@ -88,6 +98,9 @@ type RollupManagerQuerier interface { // aggchain-type rollups. ChainID is the rollup's L2 chain id. RollupIDToRollupData(opts *bind.CallOpts, rollupID uint32) ( agglayermanager.AgglayerManagerRollupDataReturn, error) + // BridgeAddress returns the bridge contract address the rollup manager was constructed with: an + // immutable constructor parameter, so the same value for the lifetime of the contract. + BridgeAddress(opts *bind.CallOpts) (common.Address, error) } // RollupContractReader reads the two on-chain sources (metadata and trusted-sequencer URL) from a diff --git a/bridgeservicefinder/mocks/mock_rollup_manager_querier.go b/bridgeservicefinder/mocks/mock_rollup_manager_querier.go index 9fd9238e3..d1d3cad3b 100644 --- a/bridgeservicefinder/mocks/mock_rollup_manager_querier.go +++ b/bridgeservicefinder/mocks/mock_rollup_manager_querier.go @@ -6,6 +6,8 @@ import ( agglayermanager "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" bind "github.com/ethereum/go-ethereum/accounts/abi/bind" + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" ) @@ -22,6 +24,64 @@ func (_m *RollupManagerQuerier) EXPECT() *RollupManagerQuerier_Expecter { return &RollupManagerQuerier_Expecter{mock: &_m.Mock} } +// BridgeAddress provides a mock function with given fields: opts +func (_m *RollupManagerQuerier) BridgeAddress(opts *bind.CallOpts) (common.Address, error) { + ret := _m.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for BridgeAddress") + } + + var r0 common.Address + var r1 error + if rf, ok := ret.Get(0).(func(*bind.CallOpts) (common.Address, error)); ok { + return rf(opts) + } + if rf, ok := ret.Get(0).(func(*bind.CallOpts) common.Address); ok { + r0 = rf(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Address) + } + } + + if rf, ok := ret.Get(1).(func(*bind.CallOpts) error); ok { + r1 = rf(opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RollupManagerQuerier_BridgeAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BridgeAddress' +type RollupManagerQuerier_BridgeAddress_Call struct { + *mock.Call +} + +// BridgeAddress is a helper method to define mock.On call +// - opts *bind.CallOpts +func (_e *RollupManagerQuerier_Expecter) BridgeAddress(opts interface{}) *RollupManagerQuerier_BridgeAddress_Call { + return &RollupManagerQuerier_BridgeAddress_Call{Call: _e.mock.On("BridgeAddress", opts)} +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) Run(run func(opts *bind.CallOpts)) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*bind.CallOpts)) + }) + return _c +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) Return(_a0 common.Address, _a1 error) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) RunAndReturn(run func(*bind.CallOpts) (common.Address, error)) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Return(run) + return _c +} + // RollupCount provides a mock function with given fields: opts func (_m *RollupManagerQuerier) RollupCount(opts *bind.CallOpts) (uint32, error) { ret := _m.Called(opts) diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go index 803bd5a5f..3a6c4a283 100644 --- a/bridgetracker/activity.go +++ b/bridgetracker/activity.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "time" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/domain" @@ -15,11 +16,28 @@ import ( // compile-time check: ActivityCache fulfils ActivityQuerier var _ ActivityQuerier = (*ActivityCache)(nil) +// activityAddrCache is the per-from_address state ActivityCache keeps: every bridge found for +// it so far, and when it was last requested (see ActivityCache.addrCache, which stamps this on +// every GetActivity call and is what the idle sweep evicts by — the same lastAccess/PruneIdle +// idea memoryRegistry uses for tracking, see registry.go's bridgeEntry) +type activityAddrCache struct { + entries map[string]*domain.ActivityEntry // key: bridge.GlobalIndex.String() + // lastAccess is when this address was last requested; addresses idle past idleTimeout are + // forgotten (see ActivityCache.addrCache) + lastAccess time.Time +} + // ActivityCache implements domain.ActivityQuerier: for a given from_address it scans every -// configured bridge service (via ActivityBridgeScanner) and keeps a running per-address cache -// of the resulting bridges, so an already-settled bridge — claimed, with its claim record -// already fetched — is never rechecked again. Every other entry (new, still unclaimed, or -// claimed but not yet indexed by the destination bridge service) is rechecked on every call. +// configured bridge service (via ActivityBridgeScanner) for bridges it has not already cached, +// and keeps a running per-address cache of the resulting bridges, so: +// - a bridge already known is never re-scanned from the bridge service again (see +// ActivityBridgeScanner.BridgesFrom's known parameter); +// - once a bridge is confirmed claimed, isClaimed() is never asked again for it, even if its +// claim record has not been fetched yet; +// - once a bridge's claim record has been fetched, it is never asked for again; +// - an address nobody has asked about in idleTimeout is forgotten entirely, freeing everything +// cached for it (mirrors SupervisedStore.PruneIdle's idea, without a dedicated ticker: see +// addrCache). // // Safe for concurrent use. type ActivityCache struct { @@ -28,73 +46,155 @@ type ActivityCache struct { supervised SupervisedStore logger aggkitcommon.Logger + idleTimeout time.Duration + // now is the clock lastAccess is stamped with and the idle sweep compares against, + // injectable for tests (mirrors memoryRegistry.now, registry.go) + now func() time.Time + mu sync.Mutex - byAddr map[common.Address]map[string]*domain.ActivityEntry // key: bridge.GlobalIndex.String() + byAddr map[common.Address]*activityAddrCache } // NewActivityCache returns an ActivityCache resolving bridges through scanner, claim state -// through claims, and (when asked) tracker registration through supervised +// through claims, and (when asked) tracker registration through supervised. idleTimeout is how +// long an address survives with no GetActivity call for it before being forgotten; <= 0 falls +// back to DefaultIdleTimeout func NewActivityCache( scanner ActivityBridgeScanner, claims ActivityClaimChecker, supervised SupervisedStore, - logger aggkitcommon.Logger, + logger aggkitcommon.Logger, idleTimeout time.Duration, ) *ActivityCache { + if idleTimeout <= 0 { + idleTimeout = DefaultIdleTimeout.Duration + } return &ActivityCache{ - scanner: scanner, - claims: claims, - supervised: supervised, - logger: logger, - byAddr: make(map[common.Address]map[string]*domain.ActivityEntry), + scanner: scanner, + claims: claims, + supervised: supervised, + logger: logger, + idleTimeout: idleTimeout, + now: time.Now, + byAddr: make(map[common.Address]*activityAddrCache), } } -// GetActivity implements domain.ActivityQuerier +// GetActivity implements domain.ActivityQuerier: it rechecks every bridge already cached for +// fromAddress that is not yet settled (see settled — their raw bridge data is already cached, so +// this needs no bridge-service call), then scans for bridges not seen before (see +// ActivityBridgeScanner.BridgesFrom), and returns everything cached for fromAddress that matches +// filter func (a *ActivityCache) GetActivity( - ctx context.Context, fromAddress common.Address, includeTracking bool, + ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, ) ([]*domain.ActivityEntry, error) { - items, err := a.scanner.BridgesFrom(ctx, fromAddress) - if err != nil { - return nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) - } - addrCache := a.addrCache(fromAddress) - for _, item := range items { - key := item.GlobalIndex.String() + a.mu.Lock() + known := make(map[string]struct{}, len(addrCache.entries)) + cached := make([]*domain.ActivityEntry, 0, len(addrCache.entries)) + for key, entry := range addrCache.entries { + known[key] = struct{}{} + cached = append(cached, entry) + } + a.mu.Unlock() + + for _, entry := range cached { + a.upsert(ctx, addrCache, entry.Bridge, includeTracking, filter) + } - a.mu.Lock() - existing := addrCache[key] - a.mu.Unlock() + newItems, err := a.scanner.BridgesFrom(ctx, fromAddress, known) + if err != nil { + return nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) + } + for _, item := range newItems { + a.upsert(ctx, addrCache, item, includeTracking, filter) + } - if existing != nil && settled(existing) { - continue + a.mu.Lock() + defer a.mu.Unlock() + out := make([]*domain.ActivityEntry, 0, len(addrCache.entries)) + for _, entry := range addrCache.entries { + if matchesFilter(entry, filter) { + out = append(out, entry) } + } + return out, nil +} - entry := a.refresh(ctx, item, includeTracking) +// upsert (re)computes item's entry via refresh and stores it, unless it is already cached and +// settled — in which case it is left untouched. Safe to call with an item the caller cannot be +// sure is genuinely new (e.g. a defensive re-check, or a pagination-boundary duplicate): settled +// entries are never redundantly refreshed regardless of where item came from +func (a *ActivityCache) upsert( + ctx context.Context, addrCache *activityAddrCache, item *bridgeservicetypes.BridgeResponse, + includeTracking bool, filter types.ActivityFilter, +) { + key := item.GlobalIndex.String() - a.mu.Lock() - addrCache[key] = entry - a.mu.Unlock() + a.mu.Lock() + existing := addrCache.entries[key] + a.mu.Unlock() + + if existing != nil && settled(existing) { + return } + entry := a.refresh(ctx, item, existing, includeTracking, filter) + a.mu.Lock() - defer a.mu.Unlock() - out := make([]*domain.ActivityEntry, 0, len(addrCache)) - for _, entry := range addrCache { - out = append(out, entry) + addrCache.entries[key] = entry + a.mu.Unlock() +} + +// matchesFilter reports whether entry belongs in a GetActivity result under filter: +// ActivityFilterAll always matches; the other filters match exactly one ClaimStatus each — see +// ActivityFilter's doc for what each one means +func matchesFilter(entry *domain.ActivityEntry, filter types.ActivityFilter) bool { + switch filter { + case types.ActivityFilterClaimed: + return entry.ClaimStatus == types.ClaimStatusClaimed + case types.ActivityFilterPending: + return entry.ClaimStatus == types.ClaimStatusUnclaimed + case types.ActivityFilterError: + return entry.ClaimStatus == types.ClaimStatusError + case types.ActivityFilterAll: + return true + default: + return true } - return out, nil } -// addrCache returns (creating if necessary) the per-address cache map for fromAddress -func (a *ActivityCache) addrCache(fromAddress common.Address) map[string]*domain.ActivityEntry { +// skipsClaimInfo reports whether filter excludes a claimed bridge from its result, making the +// destination bridge service's claim record unnecessary to fetch right now (see refresh) +func skipsClaimInfo(filter types.ActivityFilter) bool { + return filter == types.ActivityFilterPending || filter == types.ActivityFilterError +} + +// addrCache returns (creating if necessary) the per-address cache for fromAddress, stamping its +// lastAccess with now. Before that, it sweeps every address whose lastAccess is older than +// idleTimeout out of byAddr — the idle-eviction sweep. There is no dedicated ticker/goroutine for +// this (unlike SupervisedStore.PruneIdle, which the tracking engine drives on its own poll +// ticker, see engine.go's tick): ActivityCache has no background loop of its own to piggyback +// on, and sweeping on every real request is cheap for the expected number of distinct addresses. +func (a *ActivityCache) addrCache(fromAddress common.Address) *activityAddrCache { a.mu.Lock() defer a.mu.Unlock() + now := a.now() + cutoff := now.Add(-a.idleTimeout) + for addr, cache := range a.byAddr { + // fromAddress itself is deliberately not exempted: if it was already idle-expired as of + // its last access, its stale state is forgotten and it starts fresh below, exactly as if + // this were its first request + if cache.lastAccess.Before(cutoff) { + delete(a.byAddr, addr) + } + } + addrCache, ok := a.byAddr[fromAddress] if !ok { - addrCache = make(map[string]*domain.ActivityEntry) + addrCache = &activityAddrCache{entries: make(map[string]*domain.ActivityEntry)} a.byAddr[fromAddress] = addrCache } + addrCache.lastAccess = now return addrCache } @@ -106,26 +206,47 @@ func settled(entry *domain.ActivityEntry) bool { return entry.ClaimStatus == types.ClaimStatusClaimed && entry.Claim != nil } -// refresh (re)computes the claim/tracking state of a single bridge item: the on-chain -// isClaimed() call, then either the destination bridge service's claim record (once claimed) -// or — only if includeTracking — the tracker's current snapshot for the still-unclaimed tx. -// A failure at any step is logged and left for the next call to retry; it never fails the -// whole GetActivity call, since one bad network should not hide every other bridge found +// refresh (re)computes the claim/tracking state of a single bridge item. existing is the +// previously cached entry for this same bridge, or nil if it has never been seen before: +// - if existing is already confirmed claimed, isClaimed() is not asked again — that result +// never reverts — and refresh goes straight to the claim-record step; +// - otherwise the on-chain isClaimed() call runs as usual (unclaimed and error states must +// keep being re-verified, since only a confirmed claim is permanent). +// +// Once claimed, the destination bridge service's claim record is fetched — skipped when filter +// excludes claimed bridges anyway (see skipsClaimInfo); the entry then simply stays unsettled +// and is fetched normally the next time a filter that needs it is used (see settled) — or, only +// if includeTracking, the tracker's current snapshot is attached for the still-unclaimed tx. A +// failure at any step is logged and left for the next call to retry; it never fails the whole +// GetActivity call, since one bad network should not hide every other bridge found func (a *ActivityCache) refresh( - ctx context.Context, item *bridgeservicetypes.BridgeResponse, includeTracking bool, + ctx context.Context, item *bridgeservicetypes.BridgeResponse, existing *domain.ActivityEntry, + includeTracking bool, filter types.ActivityFilter, ) *domain.ActivityEntry { entry := &domain.ActivityEntry{Bridge: item} - claimed, err := a.claims.IsClaimed(ctx, item) - if err != nil { - a.logger.Warnf("activity: checking claim state of bridge tx=%s (origin network=%d, deposit=%d): %v", - item.TxHash, item.OriginNetwork, item.DepositCount, err) - entry.ClaimStatus = types.ClaimStatusError - return entry + if existing != nil && existing.ClaimStatus == types.ClaimStatusClaimed { + entry.ClaimStatus = types.ClaimStatusClaimed + } else { + claimed, err := a.claims.IsClaimed(ctx, item) + if err != nil { + a.logger.Warnf("activity: checking claim state of bridge tx=%s (origin network=%d, deposit=%d): %v", + item.TxHash, item.OriginNetwork, item.DepositCount, err) + entry.ClaimStatus = types.ClaimStatusError + entry.Errors = map[string]string{"claim": err.Error()} + return entry + } + if claimed { + entry.ClaimStatus = types.ClaimStatusClaimed + } else { + entry.ClaimStatus = types.ClaimStatusUnclaimed + } } - if claimed { - entry.ClaimStatus = types.ClaimStatusClaimed + if entry.ClaimStatus == types.ClaimStatusClaimed { + if skipsClaimInfo(filter) { + return entry + } claim, err := a.claims.ClaimInfo(ctx, item) if err != nil { a.logger.Warnf("activity: fetching claim record of bridge tx=%s: %v", item.TxHash, err) @@ -133,7 +254,6 @@ func (a *ActivityCache) refresh( entry.Claim = claim return entry } - entry.ClaimStatus = types.ClaimStatusUnclaimed if includeTracking { id := domain.TrackingID{NetworkID: item.OriginNetwork, TxHash: common.HexToHash(string(item.TxHash))} diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go index ccaada625..d82240199 100644 --- a/bridgetracker/activity_test.go +++ b/bridgetracker/activity_test.go @@ -5,6 +5,7 @@ import ( "errors" "math/big" "testing" + "time" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/domain" @@ -26,19 +27,32 @@ func testBridge(globalIndex int64) *bridgeservicetypes.BridgeResponse { } } -// fakeActivityScanner is a hand-rolled ActivityBridgeScanner for tests: bridges is returned on -// every BridgesFrom call, and calls records how many times it was invoked. +// fakeActivityScanner is a hand-rolled ActivityBridgeScanner for tests: it returns whichever of +// bridges is not in known, mirroring ActivitySource.BridgesFrom's real contract. calls records +// how many times it was invoked, lastKnown the known argument it was last called with. type fakeActivityScanner struct { - bridges []*bridgeservicetypes.BridgeResponse - err error - calls int + bridges []*bridgeservicetypes.BridgeResponse + err error + calls int + lastKnown map[string]struct{} } func (f *fakeActivityScanner) BridgesFrom( - context.Context, common.Address, + _ context.Context, _ common.Address, known map[string]struct{}, ) ([]*bridgeservicetypes.BridgeResponse, error) { f.calls++ - return f.bridges, f.err + f.lastKnown = known + if f.err != nil { + return nil, f.err + } + out := make([]*bridgeservicetypes.BridgeResponse, 0, len(f.bridges)) + for _, b := range f.bridges { + if _, ok := known[b.GlobalIndex.String()]; ok { + continue + } + out = append(out, b) + } + return out, nil } // fakeActivityClaims is a hand-rolled ActivityClaimChecker for tests: isClaimed/claimInfo are @@ -71,9 +85,11 @@ func (f *fakeActivityClaims) ClaimInfo( return claim, nil } +// newTestActivityCache builds an ActivityCache with a one-hour idle timeout, long enough that +// no test below evicts anything by accident; tests exercising eviction build their own directly. func newTestActivityCache(scanner ActivityBridgeScanner, claims ActivityClaimChecker) *ActivityCache { supervised := NewMemoryRegistry(10) - return NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test")) + return NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test"), time.Hour) } // TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall verifies an unclaimed bridge's claim @@ -87,7 +103,7 @@ func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { cache := newTestActivityCache(scanner, claims) for range 2 { - entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) @@ -108,7 +124,7 @@ func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, true) + entries, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) @@ -129,12 +145,12 @@ func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Equal(t, claim, entries[0].Claim) - entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, claim, entries[0].Claim) require.Equal(t, 1, claims.isClaimedCalls) @@ -142,29 +158,34 @@ func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { require.Equal(t, 2, scanner.calls) // BridgesFrom is still called every time to find new bridges } -// TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried verifies a bridge reported as -// claimed on-chain, but whose claim record the destination bridge service has not indexed yet -// (ClaimInfo returns nil), is retried on the next call. +// TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried verifies a bridge reported as claimed +// on-chain, but whose claim record the destination bridge service has not indexed yet (ClaimInfo +// returns nil), has its claim record retried on the next call — without asking isClaimed() again, +// since a confirmed claim never reverts (see ActivityCache.refresh). func TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried(t *testing.T) { bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + // a single isClaimed entry: a second consultation would panic on out-of-range, proving it is + // never asked again once confirmed claimed claims := &fakeActivityClaims{ - isClaimed: []bool{true, true}, + isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{nil, claim}, } cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Nil(t, entries[0].Claim) - entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls, "isClaimed must not be asked again once confirmed claimed") + require.Equal(t, 2, claims.claimInfoCalls) } // TestActivityCache_ScannerErrorFailsTheCall verifies a scanner failure fails GetActivity @@ -174,13 +195,14 @@ func TestActivityCache_ScannerErrorFailsTheCall(t *testing.T) { scanner := &fakeActivityScanner{err: wantErr} cache := newTestActivityCache(scanner, &fakeActivityClaims{}) - _, err := cache.GetActivity(t.Context(), testFromAddress, false) + _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.ErrorIs(t, err, wantErr) } // TestActivityCache_IsClaimedFailureReportsErrorStatus verifies a failed isClaimed() check // (e.g. no bridge contract address configured for the destination network) is reported as -// ClaimStatusError — never silently as ClaimStatusUnclaimed — and is retried on the next call. +// ClaimStatusError — never silently as ClaimStatusUnclaimed — and is retried on the next call +// (unlike a confirmed claim, an error is not permanent). func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { bridge := testBridge(1) scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} @@ -191,14 +213,176 @@ func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false) + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) require.Nil(t, entries[0].Claim) + require.Equal(t, "no bridge contract address configured for network 2", entries[0].Errors["claim"]) // the error state is not settled: it is retried on the next call - entries, err = cache.GetActivity(t.Context(), testFromAddress, false) + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) require.Equal(t, 2, claims.isClaimedCalls) + require.Nil(t, entries[0].Errors, "a successful recheck must not carry over the previous failure") +} + +// TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo verifies +// ActivityFilterPending returns only confirmed-unclaimed bridges — excluding both claimed ones +// and ones whose isClaimed() check errored — and never fetches a claimed bridge's claim record +// (only IsClaimed is consulted, never ClaimInfo). +func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t *testing.T) { + claimedBridge := testBridge(1) + pendingBridge := testBridge(2) + erroredBridge := testBridge(3) + scanner := &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge, erroredBridge}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called + isClaimedErrs: []error{nil, nil, errors.New("boom")}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, pendingBridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo verifies ActivityFilterError +// returns only bridges whose isClaimed() check failed, excludes claimed and pending ones, and +// never fetches a claimed bridge's claim record for this filter either. +func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing.T) { + claimedBridge := testBridge(1) + pendingBridge := testBridge(2) + erroredBridge := testBridge(3) + wantErr := errors.New("boom") + scanner := &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge, erroredBridge}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called + isClaimedErrs: []error{nil, nil, wantErr}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterError) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, erroredBridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) + require.Equal(t, wantErr.Error(), entries[0].Errors["claim"]) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_FilterClaimedExcludesPending verifies ActivityFilterClaimed returns only +// confirmed-claimed bridges, excluding both unclaimed ones and ones whose check errored. +func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { + claimedBridge := testBridge(1) + unclaimedBridge := testBridge(2) + erroredBridge := testBridge(3) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + + scanner := &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, unclaimedBridge, erroredBridge}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, + isClaimedErrs: []error{nil, nil, errors.New("boom")}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterClaimed) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, claimedBridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) +} + +// TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed verifies a bridge left +// unsettled by ActivityFilterPending (claimed, but its claim record deliberately not fetched) +// gets its claim record fetched normally the next time ActivityFilterAll is used — without +// isClaimed() being asked again, since it was already confirmed claimed. +func TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + // a single isClaimed entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + + // filterBridges=pending: claimed, but its claim record is deliberately not fetched, and the + // bridge itself is excluded from this result + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + require.NoError(t, err) + require.Empty(t, entries) + require.Equal(t, 0, claims.claimInfoCalls) + + // filterBridges=all: the still-unsettled entry is rechecked — its claim record is fetched, + // but isClaimed() is not asked again + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls) + require.Equal(t, 1, claims.claimInfoCalls) +} + +// TestActivityCache_ScannerReceivesGrowingKnownSet verifies the scanner is called with an empty +// known set the first time (nothing cached yet), and with the previously found bridge's key once +// it has been cached. +func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + + _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Empty(t, scanner.lastKnown, "nothing cached yet on the first call") + + _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Contains(t, scanner.lastKnown, bridge.GlobalIndex.String()) +} + +// TestActivityCache_IdleAddressIsForgotten verifies an address untouched for longer than +// idleTimeout is forgotten entirely: proven indirectly by observing isClaimed() being asked +// again for a bridge that had already settled — which would not happen if its cached state had +// survived. +func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{ + isClaimed: []bool{true, true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim, claim}, + } + + supervised := NewMemoryRegistry(10) + cache := NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test"), time.Minute) + now := time.Now() + cache.now = func() time.Time { return now } + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls, "settled after the first call") + + now = now.Add(2 * time.Minute) // past idleTimeout + + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 2, claims.isClaimedCalls, "the address was forgotten, so isClaimed is asked again from scratch") } diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go index 5004c5b8e..af93fbac6 100644 --- a/bridgetracker/api/activity_command.go +++ b/bridgetracker/api/activity_command.go @@ -44,6 +44,9 @@ type ActivityItem struct { // Tracking is the bridge tracker's current status for this bridge; only present when the // request set includeTracking=true and the bridge is still unclaimed Tracking *TrackingData `json:"tracking,omitempty"` + // Errors holds the message of whatever check failed the last time this item was refreshed, + // keyed by which check it was — currently only "claim", present when Claimed is "error" + Errors map[string]string `json:"errors,omitempty"` } // ActivityResponse is the body of GET /activity/from/{from_address} @@ -58,8 +61,11 @@ type ActivityResponse struct { // from_address path parameter, and reports each one's claim state. Passing // ?includeTracking=true additionally registers every still-unclaimed bridge found with the // bridge tracker (same effect as calling GetTxStatus for it) and includes its current tracking -// snapshot. -// 200 OK unless: invalid from_address (ErrorData/400), or the scan itself failed (ErrorData/500) +// snapshot. ?filterBridges=claimed|pending|error restricts the result to only bridges with that +// claim state (default "all"); a claimed bridge excluded by "pending"/"error" never has its +// claim record fetched, so switching back to "all"/"claimed" later fetches it then. +// 200 OK unless: invalid from_address/filterBridges (ErrorData/400), or the scan itself failed +// (ErrorData/500) // // @Summary Get bridge activity by sender address // @Description Scans every bridge service the tracker knows about for bridges sent by @@ -67,13 +73,16 @@ type ActivityResponse struct { // @Description reported it. Results are cached: a bridge already known to be claimed, with its // @Description claim record already fetched, is not rechecked on a later call. Passing // @Description includeTracking=true additionally registers every still-unclaimed bridge with -// @Description the bridge tracker and includes its current tracking snapshot. +// @Description the bridge tracker and includes its current tracking snapshot. filterBridges +// @Description restricts the result to bridges with only that claim state (claimed / still +// @Description pending / errored while checking). // @Tags bridge-tracker // @Produce json // @Param from_address path string true "Address that sent the bridges to look up" // @Param includeTracking query bool false "Register still-unclaimed bridges with the tracker" +// @Param filterBridges query string false "Which bridges to return" Enums(all, claimed, pending, error) default(all) // @Success 200 {object} ActivityResponse -// @Failure 400 {object} types.ErrorData "Invalid from_address" +// @Failure 400 {object} types.ErrorData "Invalid from_address or filterBridges" // @Failure 500 {object} types.ErrorData "Scanning the configured bridge services failed" // @Router /activity/from/{from_address} [get] func (cmd *activityCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) { @@ -84,7 +93,12 @@ func (cmd *activityCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) fromAddress := common.HexToAddress(addrStr) includeTracking := c.Query(includeTrackingQueryParam) == "true" - entries, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking) + filter, err := types.ParseActivityFilter(c.Query(filterBridgesQueryParam)) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: err.Error()} + } + + entries, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking, filter) if err != nil { return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} } @@ -100,6 +114,7 @@ func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { Bridge: e.Bridge, BridgeNetworkID: e.Bridge.OriginNetwork, Claimed: e.ClaimStatus.String(), + Errors: e.Errors, } if e.Claim != nil { item.Claim = e.Claim diff --git a/bridgetracker/api/api.go b/bridgetracker/api/api.go index 8ccb4bc88..9005ba2a8 100644 --- a/bridgetracker/api/api.go +++ b/bridgetracker/api/api.go @@ -42,6 +42,10 @@ const ( // activityCommand.Execute) includeTrackingQueryParam = "includeTracking" + // filterBridgesQueryParam selects which bridges the activity endpoint returns: "all" + // (default), "claimed" or "pending" (see types.ActivityFilter) + filterBridgesQueryParam = "filterBridges" + decimalBase = 10 uint32BitSize = 32 ) diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index be7913631..9d99b9540 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -24,7 +24,7 @@ const docTemplate = `{ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", "produces": [ "application/json" ], @@ -45,6 +45,19 @@ const docTemplate = `{ "description": "Register still-unclaimed bridges with the tracker", "name": "includeTracking", "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" } ], "responses": { @@ -55,7 +68,7 @@ const docTemplate = `{ } }, "400": { - "description": "Invalid from_address", + "description": "Invalid from_address or filterBridges", "schema": { "$ref": "#/definitions/types.ErrorData" } @@ -205,6 +218,13 @@ const docTemplate = `{ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ @@ -401,17 +421,38 @@ const docTemplate = `{ 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, - 60000000000 + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000 ], "x-enum-varnames": [ "minDuration", @@ -422,17 +463,38 @@ const docTemplate = `{ "Second", "Minute", "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute" + "Minute", + "Hour" ] } } diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 43dc2af4d..3bfdcc857 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -17,7 +17,7 @@ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", "produces": [ "application/json" ], @@ -38,6 +38,19 @@ "description": "Register still-unclaimed bridges with the tracker", "name": "includeTracking", "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" } ], "responses": { @@ -48,7 +61,7 @@ } }, "400": { - "description": "Invalid from_address", + "description": "Invalid from_address or filterBridges", "schema": { "$ref": "#/definitions/types.ErrorData" } @@ -198,6 +211,13 @@ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ @@ -394,17 +414,38 @@ 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, - 60000000000 + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000 ], "x-enum-varnames": [ "minDuration", @@ -415,17 +456,38 @@ "Second", "Minute", "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute" + "Minute", + "Hour" ] } } diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index f269bfe7f..24275c4ac 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -30,6 +30,13 @@ definitions: "error" if the check itself failed (e.g. no bridge contract address configured for the destination network) — callers must not read "error" as "false" type: string + errors: + additionalProperties: + type: string + description: |- + Errors holds the message of whatever check failed the last time this item was refreshed, + keyed by which check it was — currently only "claim", present when Claimed is "error" + type: object tracking: allOf: - $ref: '#/definitions/api.TrackingData' @@ -203,17 +210,38 @@ definitions: - 1000000000 - 60000000000 - 3600000000000 + - -9223372036854775808 + - 9223372036854775807 + - 1 + - 1000 + - 1000000 + - 1000000000 + - 60000000000 + - 3600000000000 + - -9223372036854775808 + - 9223372036854775807 + - 1 + - 1000 + - 1000000 + - 1000000000 + - 60000000000 + - 3600000000000 + - -9223372036854775808 + - 9223372036854775807 - 1 - 1000 - 1000000 - 1000000000 - 60000000000 - 3600000000000 + - -9223372036854775808 + - 9223372036854775807 - 1 - 1000 - 1000000 - 1000000000 - 60000000000 + - 3600000000000 format: int64 type: integer x-enum-varnames: @@ -225,17 +253,38 @@ definitions: - Second - Minute - Hour + - minDuration + - maxDuration + - Nanosecond + - Microsecond + - Millisecond + - Second + - Minute + - Hour + - minDuration + - maxDuration - Nanosecond - Microsecond - Millisecond - Second - Minute - Hour + - minDuration + - maxDuration - Nanosecond - Microsecond - Millisecond - Second - Minute + - Hour + - minDuration + - maxDuration + - Nanosecond + - Microsecond + - Millisecond + - Second + - Minute + - Hour type: object types.BridgeResponse: description: Detailed information about a bridge event @@ -499,7 +548,9 @@ paths: reported it. Results are cached: a bridge already known to be claimed, with its claim record already fetched, is not rechecked on a later call. Passing includeTracking=true additionally registers every still-unclaimed bridge with - the bridge tracker and includes its current tracking snapshot. + the bridge tracker and includes its current tracking snapshot. filterBridges + restricts the result to bridges with only that claim state (claimed / still + pending / errored while checking). parameters: - description: Address that sent the bridges to look up in: path @@ -510,6 +561,16 @@ paths: in: query name: includeTracking type: boolean + - default: all + description: Which bridges to return + enum: + - all + - claimed + - pending + - error + in: query + name: filterBridges + type: string produces: - application/json responses: @@ -518,7 +579,7 @@ paths: schema: $ref: '#/definitions/api.ActivityResponse' "400": - description: Invalid from_address + description: Invalid from_address or filterBridges schema: $ref: '#/definitions/types.ErrorData' "500": diff --git a/bridgetracker/bridgetracker.go b/bridgetracker/bridgetracker.go index 3b6c60947..8cde968c8 100644 --- a/bridgetracker/bridgetracker.go +++ b/bridgetracker/bridgetracker.go @@ -34,7 +34,8 @@ func New(cfg *Config) *BridgeTracker { // Config.ActivityScanner/ActivityClaims); a nil ActivityQuerier tells api.NewAPI to skip it var activity ActivityQuerier if cfg.ActivityScanner != nil && cfg.ActivityClaims != nil { - activity = NewActivityCache(cfg.ActivityScanner, cfg.ActivityClaims, supervised, cfg.Logger) + activity = NewActivityCache( + cfg.ActivityScanner, cfg.ActivityClaims, supervised, cfg.Logger, cfg.ActivityIdleTimeout.Duration) } return &BridgeTracker{ diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 50f3b76d0..21b47791d 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -3,6 +3,7 @@ package bridgetracker import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -390,3 +391,82 @@ func TestActivityHandlerHappyPath(t *testing.T) { require.Equal(t, claim.TxHash, body.Bridges[0].Claim.TxHash) require.Equal(t, bridge.DestinationNetwork, body.Bridges[0].ClaimNetworkID) } + +// TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage verifies a failed isClaimed() +// check surfaces as claimed="error" plus the failure message under errors["claim"], instead of +// being silently reported as unclaimed. +func TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage(t *testing.T) { + bridge := testBridge(1) + wantErrMsg := "no bridge contract address configured for network 2" + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{bridge}, + }, + ActivityClaims: &fakeActivityClaims{ + isClaimed: []bool{false}, + isClaimedErrs: []error{errors.New(wantErrMsg)}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Equal(t, "error", body.Bridges[0].Claimed) + require.Nil(t, body.Bridges[0].Claim) + require.Equal(t, wantErrMsg, body.Bridges[0].Errors["claim"]) +} + +// TestActivityHandlerInvalidFilterBridges verifies an unrecognized filterBridges value is +// rejected with 400 +func TestActivityHandlerInvalidFilterBridges(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{}, + ActivityClaims: &fakeActivityClaims{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, + api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()+"?filterBridges=bogus") + require.Equal(t, http.StatusBadRequest, resp.Code) +} + +// TestActivityHandlerFilterBridgesPendingExcludesClaimed verifies ?filterBridges=pending +// excludes an already-claimed bridge from the response +func TestActivityHandlerFilterBridgesPendingExcludesClaimed(t *testing.T) { + claimedBridge := testBridge(1) + pendingBridge := testBridge(2) + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge}, + }, + ActivityClaims: &fakeActivityClaims{isClaimed: []bool{true, false}}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, + api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()+"?filterBridges=pending") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Equal(t, "false", body.Bridges[0].Claimed) +} diff --git a/bridgetracker/config.go b/bridgetracker/config.go index bb886e687..e40edefa1 100644 --- a/bridgetracker/config.go +++ b/bridgetracker/config.go @@ -124,6 +124,13 @@ type Config struct { // both); leaving either nil leaves the endpoint unregistered entirely. ActivityScanner ActivityBridgeScanner `mapstructure:"-"` ActivityClaims ActivityClaimChecker `mapstructure:"-"` + + // ActivityIdleTimeout is how long a from_address's activity cache (see ActivityCache) stays + // in memory with no GET /activity/from/{from_address} call for it, before being forgotten + // entirely (bridges, claim state, everything cached for it). Same semantics as IdleTimeout, + // a separate knob because it governs a different cache. A value <= 0 falls back to + // DefaultIdleTimeout. + ActivityIdleTimeout types.Duration `mapstructure:"ActivityIdleTimeout"` } // Validate checks if the configuration is valid diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go index 8e56cb235..8405e6d9e 100644 --- a/bridgetracker/domain/activity.go +++ b/bridgetracker/domain/activity.go @@ -26,15 +26,26 @@ type ActivityEntry struct { // Tracking is the bridge tracker's current snapshot of this bridge, only populated while // it is still unclaimed and the caller asked for it (includeTracking); nil otherwise Tracking *TrackingData + // Errors holds the message of whatever check failed the last time this entry was + // refreshed, keyed by which check it was — currently only "claim", set when ClaimStatus is + // Error (the isClaimed() check itself failed). nil while nothing has failed + Errors map[string]string } // ActivityBridgeScanner is the driven port to the raw bridge-service data behind the // GET /activity/from/{from_address} endpoint: it scans every bridge service the tracker knows // about for bridges sent by fromAddress type ActivityBridgeScanner interface { - // BridgesFrom returns every bridge whose sender is fromAddress, across every configured - // bridge service, exactly as each network's own bridge service reports it - BridgesFrom(ctx context.Context, fromAddress common.Address) ([]*bridgeservicetypes.BridgeResponse, error) + // BridgesFrom returns every bridge whose sender is fromAddress and whose GlobalIndex (as a + // decimal string) is not already in known, across every configured bridge service. known is + // the caller's full set of already-cached global indexes for fromAddress (any network — a + // GlobalIndex is unique across the whole system); implementations may use it to stop + // scanning a network as soon as an already-known bridge is reached, since each network's + // own bridge service reports bridges newest-first and is append-only, so anything after the + // first known bridge is guaranteed already known too (see sources.ActivitySource) + BridgesFrom( + ctx context.Context, fromAddress common.Address, known map[string]struct{}, + ) ([]*bridgeservicetypes.BridgeResponse, error) } // ActivityClaimChecker is the driven port to a bridge's claim state on its destination @@ -51,8 +62,11 @@ type ActivityClaimChecker interface { // ActivityQuerier is the driven port the GET /activity/from/{from_address} HTTP command // depends on type ActivityQuerier interface { - // GetActivity returns every bridge sent by fromAddress across every configured bridge - // service, enriched with its claim state; includeTracking additionally feeds every - // still-unclaimed bridge to the bridge tracker (see ActivityEntry.Tracking) - GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool) ([]*ActivityEntry, error) + // GetActivity returns the bridges sent by fromAddress across every configured bridge + // service, enriched with their claim state and filtered per filter (see + // types.ActivityFilter); includeTracking additionally feeds every still-unclaimed bridge in + // the result to the bridge tracker (see ActivityEntry.Tracking) + GetActivity( + ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, + ) ([]*ActivityEntry, error) } diff --git a/bridgetracker/sources/activity.go b/bridgetracker/sources/activity.go index 26f241a96..40f86084c 100644 --- a/bridgetracker/sources/activity.go +++ b/bridgetracker/sources/activity.go @@ -17,14 +17,18 @@ import ( // while scanning for a given from_address (see ActivitySource.BridgesFrom) const activityPageSize = uint32(100) -// NetworkLister widens NetworkURLResolver with network enumeration: it is the slice of -// bridgeservicefinder.Finder ActivitySource needs on top of the per-network URL lookup every -// other source already uses, so it knows which bridge services to scan for a given address -// without a fixed config list. bridgeservicefinder.Finder satisfies it. +// NetworkLister widens NetworkURLResolver with network enumeration and bridge contract address +// resolution: it is the slice of bridgeservicefinder.Finder ActivitySource needs on top of the +// per-network URL lookup every other source already uses, so it knows which bridge services to +// scan for a given address (without a fixed config list) and which contract to check +// isClaimed() against. bridgeservicefinder.Finder satisfies it. type NetworkLister interface { NetworkURLResolver // NetworkIDs returns the networkIDs of every network currently resolved NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID (see + // bridgeservicefinder.Finder.BridgeAddress for the resolution/override rules) + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) } // claimChecker is the minimal bridge contract surface ActivitySource needs to check a bridge's @@ -39,10 +43,9 @@ type claimChecker interface { // destination network — isClaimed() on the destination bridge contract as the source of truth, // then the destination bridge service's own claim record once claimed. type ActivitySource struct { - services *bridgeServiceClients - finder NetworkLister - ethClients EthClientResolver - bridgeAddrs map[uint32]common.Address + services *bridgeServiceClients + finder NetworkLister + ethClients EthClientResolver // newContract builds the claim-checking contract binding for a destination network, // injectable for tests. Defaults to agglayerbridgel2.NewAgglayerbridgel2 newContract func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) @@ -51,18 +54,15 @@ type ActivitySource struct { contracts map[uint32]claimChecker // destination networkID -> bound contract, built lazily } -// NewActivitySource returns an ActivitySource resolving bridge services and JSON-RPC clients -// through finder/ethClients, and destination bridge contract addresses through bridgeAddrs (see -// Config.BridgeAddrs) — a destination network absent from bridgeAddrs cannot be claim-checked -// (IsClaimed errors for it, see claimCheckerFor) -func NewActivitySource( - finder NetworkLister, ethClients EthClientResolver, bridgeAddrs map[uint32]common.Address, -) *ActivitySource { +// NewActivitySource returns an ActivitySource resolving bridge services, JSON-RPC clients and +// destination bridge contract addresses through finder/ethClients (see +// bridgeservicefinder.Finder.BridgeAddress for how a destination network's contract address is +// resolved and overridden) +func NewActivitySource(finder NetworkLister, ethClients EthClientResolver) *ActivitySource { return &ActivitySource{ - services: newBridgeServiceClients(finder), - finder: finder, - ethClients: ethClients, - bridgeAddrs: bridgeAddrs, + services: newBridgeServiceClients(finder), + finder: finder, + ethClients: ethClients, newContract: func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) { return agglayerbridgel2.NewAgglayerbridgel2(addr, c) }, @@ -71,11 +71,13 @@ func NewActivitySource( } // BridgesFrom implements bridgetracker.ActivityBridgeScanner: it queries every network's own -// bridge service GET /bridge/v1/bridges filtered by from_address, paging until a short page. A -// network that cannot be reached is logged and skipped rather than failing the whole scan, so -// one misbehaving bridge service does not hide every other network's activity. +// bridge service GET /bridge/v1/bridges filtered by from_address, paging until either a short +// page or an already-known bridge is reached (see fetchNewBridgesFrom — this relies on the +// bridge service reporting bridges newest-first). A network that cannot be reached is logged and +// skipped rather than failing the whole scan, so one misbehaving bridge service does not hide +// every other network's activity. func (s *ActivitySource) BridgesFrom( - ctx context.Context, fromAddress common.Address, + ctx context.Context, fromAddress common.Address, known map[string]struct{}, ) ([]*bridgeservicetypes.BridgeResponse, error) { addr := fromAddress.Hex() @@ -86,7 +88,7 @@ func (s *ActivitySource) BridgesFrom( return nil, fmt.Errorf("resolving bridge service client for network %d: %w", networkID, err) } - items, err := fetchAllBridgesFrom(ctx, svc, networkID, addr, activityPageSize) + items, err := fetchNewBridgesFrom(ctx, svc, networkID, addr, activityPageSize, known) if err != nil { return nil, fmt.Errorf("fetching bridges from %s on network %d: %w", fromAddress, networkID, err) } @@ -95,10 +97,15 @@ func (s *ActivitySource) BridgesFrom( return all, nil } -// fetchAllBridgesFrom pages through networkID's GET /bridge/v1/bridges filtered by fromAddress -// until a page shorter than pageSize is returned -func fetchAllBridgesFrom( +// fetchNewBridgesFrom pages through networkID's GET /bridge/v1/bridges filtered by fromAddress, +// newest bridge first (the bridge service's own order, by descending deposit_count), stopping as +// soon as either a page shorter than pageSize is returned (no more data) or a bridge already in +// known is reached. The latter is safe because the feed is append-only and strictly ordered: +// once a known bridge is seen, every bridge after it (same page or later pages) is guaranteed +// already known too, so nothing new is missed by stopping there. +func fetchNewBridgesFrom( ctx context.Context, svc *client.Client, networkID uint32, fromAddress string, pageSize uint32, + known map[string]struct{}, ) ([]*bridgeservicetypes.BridgeResponse, error) { var out []*bridgeservicetypes.BridgeResponse for page := uint32(1); ; page++ { @@ -111,7 +118,12 @@ func fetchAllBridgesFrom( if err != nil { return nil, err } - out = append(out, res.Bridges...) + for _, b := range res.Bridges { + if _, ok := known[b.GlobalIndex.String()]; ok { + return out, nil + } + out = append(out, b) + } if uint32(len(res.Bridges)) < pageSize { return out, nil } @@ -165,10 +177,9 @@ func (s *ActivitySource) claimCheckerFor(ctx context.Context, networkID uint32) return c, nil } - addr, ok := s.bridgeAddrs[networkID] - if !ok { - return nil, fmt.Errorf("no bridge contract address configured for network %d (see [Tracker].BridgeAddrs)", - networkID) + addr, err := s.finder.BridgeAddress(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving bridge contract address for network %d: %w", networkID, err) } rpcClient, err := s.ethClients.RPCClientFor(ctx, networkID) if err != nil { diff --git a/bridgetracker/sources/activity_test.go b/bridgetracker/sources/activity_test.go index 4ae99f128..eea298117 100644 --- a/bridgetracker/sources/activity_test.go +++ b/bridgetracker/sources/activity_test.go @@ -1,6 +1,7 @@ package sources import ( + "context" "encoding/json" "fmt" "math/big" @@ -79,10 +80,14 @@ func (f *fakeActivityBridgeService) start(t *testing.T) string { } // fakeNetworkLister is a fixed NetworkLister for tests: every networkID resolves to the same -// bridge service base URL +// bridge service base URL. bridgeAddrs backs BridgeAddress; a networkID absent from it errors +// (bridgeAddrErr if set, a generic "not configured" error otherwise), mirroring +// bridgeservicefinder's own behaviour when neither an override nor the on-chain default applies. type fakeNetworkLister struct { - networkIDs []uint32 - url string + networkIDs []uint32 + url string + bridgeAddrs map[uint32]common.Address + bridgeAddrErr error } func (f fakeNetworkLister) GetURL(uint32) (bridgeservicefinder.NetworkURLs, error) { @@ -91,6 +96,16 @@ func (f fakeNetworkLister) GetURL(uint32) (bridgeservicefinder.NetworkURLs, erro func (f fakeNetworkLister) NetworkIDs() []uint32 { return f.networkIDs } +func (f fakeNetworkLister) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + if addr, ok := f.bridgeAddrs[networkID]; ok { + return addr, nil + } + if f.bridgeAddrErr != nil { + return common.Address{}, f.bridgeAddrErr + } + return common.Address{}, fmt.Errorf("no bridge contract address configured for network %d", networkID) +} + func bridgeResponse(networkID, destNetwork, depositCount uint32, from string, globalIndex int64) *bridgeservicetypes.BridgeResponse { fromAddr := bridgeservicetypes.Address(from) return &bridgeservicetypes.BridgeResponse{ @@ -124,9 +139,9 @@ func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{1, 2}, url: url} - source := NewActivitySource(lister, nil, nil) + source := NewActivitySource(lister, nil) - items, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress)) + items, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) require.NoError(t, err) require.Len(t, items, 4) @@ -137,9 +152,9 @@ func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) require.ElementsMatch(t, []int64{1, 2, 3, 5}, globalIndexes) } -// TestFetchAllBridgesFrom_Pagination exercises the pagination loop directly with a small page +// TestFetchNewBridgesFrom_Pagination exercises the pagination loop directly with a small page // size, so a short page (fewer results than requested) stops the loop. -func TestFetchAllBridgesFrom_Pagination(t *testing.T) { +func TestFetchNewBridgesFrom_Pagination(t *testing.T) { svc := &fakeActivityBridgeService{ bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ 1: { @@ -151,19 +166,47 @@ func TestFetchAllBridgesFrom_Pagination(t *testing.T) { } url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} - source := NewActivitySource(lister, nil, nil) + source := NewActivitySource(lister, nil) client, err := source.services.aggkitBridgeClientFor(1) require.NoError(t, err) - items, err := fetchAllBridgesFrom(t.Context(), client, 1, testFromAddress, 2) + items, err := fetchNewBridgesFrom(t.Context(), client, 1, testFromAddress, 2, nil) require.NoError(t, err) require.Len(t, items, 3) } +// TestFetchNewBridgesFrom_StopsAtFirstKnownBridge verifies pagination stops as soon as an +// already-known bridge is reached, without walking further pages, and returns only the bridges +// found before it (the newer ones, per the server's newest-first order). +func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { + // bridgesByNetwork is given newest-first (global index 3, then 2, then 1), matching the real + // bridge service's own deposit_count DESC order + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 2, testFromAddress, 3), + bridgeResponse(1, 2, 1, testFromAddress, 2), // already known: pagination stops here + bridgeResponse(1, 2, 0, testFromAddress, 1), // must never be fetched + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} + source := NewActivitySource(lister, nil) + client, err := source.services.aggkitBridgeClientFor(1) + require.NoError(t, err) + + known := map[string]struct{}{"2": {}} + items, err := fetchNewBridgesFrom(t.Context(), client, 1, testFromAddress, 1, known) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, int64(3), items[0].GlobalIndex.Int64()) +} + // TestActivitySource_IsClaimed_NoBridgeAddrConfigured verifies IsClaimed errors clearly when // the destination network has no bridge contract address configured. func TestActivitySource_IsClaimed_NoBridgeAddrConfigured(t *testing.T) { - source := NewActivitySource(fakeNetworkLister{}, StaticClients{}, map[uint32]common.Address{}) + source := NewActivitySource(fakeNetworkLister{}, StaticClients{}) bridge := bridgeResponse(1, 2, 3, testFromAddress, 1) _, err := source.IsClaimed(t.Context(), bridge) @@ -179,7 +222,8 @@ func TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork( stub := &stubClaimChecker{claimed: true} buildCalls := 0 - source := NewActivitySource(fakeNetworkLister{}, client, map[uint32]common.Address{2: destAddr}) + lister := fakeNetworkLister{bridgeAddrs: map[uint32]common.Address{2: destAddr}} + source := NewActivitySource(lister, client) source.newContract = func(addr common.Address, _ aggkittypes.BaseEthereumClienter) (claimChecker, error) { buildCalls++ require.Equal(t, destAddr, addr) @@ -222,7 +266,7 @@ func TestActivitySource_ClaimInfo(t *testing.T) { } url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{2}, url: url} - source := NewActivitySource(lister, nil, nil) + source := NewActivitySource(lister, nil) found := bridgeResponse(1, 2, 0, testFromAddress, 1) got, err := source.ClaimInfo(t.Context(), found) diff --git a/bridgetracker/types/activity_filter.go b/bridgetracker/types/activity_filter.go new file mode 100644 index 000000000..fecb605f4 --- /dev/null +++ b/bridgetracker/types/activity_filter.go @@ -0,0 +1,53 @@ +package types + +import "fmt" + +// ActivityFilter selects which bridges GetActivity (see domain.ActivityQuerier) returns for a +// from_address, based on ClaimStatus, and doubles as a hint to skip fetching data the caller +// does not want: requesting ActivityFilterPending or ActivityFilterError skips the destination +// bridge service's claim record for a bridge found to be claimed, since it would be filtered +// out of the result anyway — that bridge's cache entry simply stays unsettled and is fetched +// normally the next time a filter that needs it is used (see bridgetracker.ActivityCache.refresh) +type ActivityFilter int + +const ( + // ActivityFilterAll returns every bridge found, regardless of claim state (the default) + ActivityFilterAll ActivityFilter = iota + // ActivityFilterClaimed returns only bridges confirmed claimed + ActivityFilterClaimed + // ActivityFilterPending returns only bridges confirmed still unclaimed (ClaimStatusUnclaimed) + // — a bridge whose claim state could not be checked is not "pending", see ActivityFilterError + ActivityFilterPending + // ActivityFilterError returns only bridges whose isClaimed() check itself failed + // (ClaimStatusError): their claim state is unknown, neither claimed nor confirmed pending + ActivityFilterError +) + +var activityFilterNames = map[ActivityFilter]string{ + ActivityFilterAll: "all", + ActivityFilterClaimed: "claimed", + ActivityFilterPending: "pending", + ActivityFilterError: "error", +} + +// String representation of the enum: "all", "claimed", "pending" or "error" +func (f ActivityFilter) String() string { + if name, ok := activityFilterNames[f]; ok { + return name + } + return fmt.Sprintf("Unknown(%d)", int(f)) +} + +// ParseActivityFilter parses the filterBridges query parameter: "" (unset) and "all" both mean +// ActivityFilterAll. Returns an error for any other value. +func ParseActivityFilter(s string) (ActivityFilter, error) { + if s == "" { + return ActivityFilterAll, nil + } + for f, name := range activityFilterNames { + if name == s { + return f, nil + } + } + return ActivityFilterAll, fmt.Errorf("invalid filterBridges %q: must be one of all, claimed, pending, error", s) +} diff --git a/bridgetracker/types/activity_filter_test.go b/bridgetracker/types/activity_filter_test.go new file mode 100644 index 000000000..38419a489 --- /dev/null +++ b/bridgetracker/types/activity_filter_test.go @@ -0,0 +1,42 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestActivityFilterString(t *testing.T) { + require.Equal(t, "all", ActivityFilterAll.String()) + require.Equal(t, "claimed", ActivityFilterClaimed.String()) + require.Equal(t, "pending", ActivityFilterPending.String()) + require.Equal(t, "error", ActivityFilterError.String()) + require.Equal(t, "Unknown(99)", ActivityFilter(99).String()) +} + +func TestParseActivityFilter(t *testing.T) { + tests := []struct { + input string + want ActivityFilter + wantErr bool + }{ + {input: "", want: ActivityFilterAll}, + {input: "all", want: ActivityFilterAll}, + {input: "claimed", want: ActivityFilterClaimed}, + {input: "pending", want: ActivityFilterPending}, + {input: "error", want: ActivityFilterError}, + {input: "bogus", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseActivityFilter(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 43dc2af4d..3bfdcc857 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -17,7 +17,7 @@ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot.", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", "produces": [ "application/json" ], @@ -38,6 +38,19 @@ "description": "Register still-unclaimed bridges with the tracker", "name": "includeTracking", "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" } ], "responses": { @@ -48,7 +61,7 @@ } }, "400": { - "description": "Invalid from_address", + "description": "Invalid from_address or filterBridges", "schema": { "$ref": "#/definitions/types.ErrorData" } @@ -198,6 +211,13 @@ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ @@ -394,17 +414,38 @@ 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, + -9223372036854775808, + 9223372036854775807, 1, 1000, 1000000, 1000000000, - 60000000000 + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000, + -9223372036854775808, + 9223372036854775807, + 1, + 1000, + 1000000, + 1000000000, + 60000000000, + 3600000000000 ], "x-enum-varnames": [ "minDuration", @@ -415,17 +456,38 @@ "Second", "Minute", "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", + "minDuration", + "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", - "Minute" + "Minute", + "Hour" ] } } diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index ce7f9b0de..1270f47ab 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -190,8 +190,9 @@ func runTracker( // GET /activity/from/{from_address} scans every network the finder knows about (via // finder.NetworkIDs) for bridges sent by an address, and resolves their claim state through - // the same per-network JSON-RPC clients and BridgeAddrs used above - activitySource := sources.NewActivitySource(finder, rpcClients, trackerCfg.BridgeAddrs) + // the same per-network JSON-RPC clients plus the finder's own BridgeAddress resolution + // (see BridgeServiceFinder.BridgeAddress, distinct from Tracker.BridgeAddrs above) + activitySource := sources.NewActivitySource(finder, rpcClients) trackerCfg.ActivityScanner = activitySource trackerCfg.ActivityClaims = activitySource diff --git a/proxy/config/default.go b/proxy/config/default.go index 1370dc38a..fed5b772b 100644 --- a/proxy/config/default.go +++ b/proxy/config/default.go @@ -27,6 +27,8 @@ IgnoreNetworkIDs = [] [BridgeServiceFinder.RPCURLs] +[BridgeServiceFinder.BridgeAddress] + [REST] Host = "0.0.0.0" Port = 8080 @@ -57,6 +59,11 @@ RetentionPeriod = "10m" # not stay in memory forever. IdleTimeout = "30m" +# ActivityIdleTimeout: how long a from_address's activity cache (GET /activity/from/{address}) +# stays in memory with no request for it, before being forgotten entirely -- same idea as +# IdleTimeout, a separate knob because it governs a different cache. +ActivityIdleTimeout = "30m" + # RegisterResolveTimeout: how long the first request for a freshly registered tx waits for the # engine's immediate resolution attempt before answering, so it has a shot at real progress # instead of the bare "registered" state; a lookup of an already-registered tx never waits. From ed56de59f797b2b33131d2d8e279412fc36c49cc Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:37:52 +0200 Subject: [PATCH 08/16] feat(bridgetracker): add creation/last-updated timestamps to activity endpoint, document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActivityEntry gains CreatedAt/UpdatedAt: CreatedAt is stamped once (or carried forward from the previous cache entry) and never changes; UpdatedAt is stamped on every refresh, so it freezes once a bridge settles (claimed with its claim record fetched) since it is never refreshed again from that point on. - ActivityItem exposes them as creation_timestamp/last_updated_timestamp (unix seconds, matching the rest of the API's timestamp fields). - docs/bridgetracker/API.md: documents the whole activity endpoint end to end (it had none before) — request params (includeTracking, filterBridges), response shape (ActivityResponse/ActivityItem), the pass-through BridgeResponse/ClaimResponse shapes, an example, and the caching/eviction behavior. Co-Authored-By: Claude Sonnet 5 --- bridgetracker/activity.go | 13 +- bridgetracker/activity_test.go | 49 +++++ bridgetracker/api/activity_command.go | 17 +- bridgetracker/api/docs/docs.go | 8 + bridgetracker/api/docs/swagger.json | 8 + bridgetracker/api/docs/swagger.yaml | 11 + bridgetracker/bridgetracker_test.go | 2 + bridgetracker/domain/activity.go | 8 + .../swagger/bridge_tracker/swagger.json | 8 + docs/bridgetracker/API.md | 191 ++++++++++++++++++ 10 files changed, 309 insertions(+), 6 deletions(-) diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go index 3a6c4a283..d1d11915a 100644 --- a/bridgetracker/activity.go +++ b/bridgetracker/activity.go @@ -206,8 +206,11 @@ func settled(entry *domain.ActivityEntry) bool { return entry.ClaimStatus == types.ClaimStatusClaimed && entry.Claim != nil } -// refresh (re)computes the claim/tracking state of a single bridge item. existing is the -// previously cached entry for this same bridge, or nil if it has never been seen before: +// refresh (re)computes the claim/tracking state of a single bridge item, stamping +// ActivityEntry.CreatedAt (carried forward from existing, or now if this is the first time) and +// UpdatedAt (always now — every call to refresh counts as an update, whether or not anything +// about the entry actually changed). existing is the previously cached entry for this same +// bridge, or nil if it has never been seen before: // - if existing is already confirmed claimed, isClaimed() is not asked again — that result // never reverts — and refresh goes straight to the claim-record step; // - otherwise the on-chain isClaimed() call runs as usual (unclaimed and error states must @@ -224,6 +227,12 @@ func (a *ActivityCache) refresh( includeTracking bool, filter types.ActivityFilter, ) *domain.ActivityEntry { entry := &domain.ActivityEntry{Bridge: item} + if existing != nil { + entry.CreatedAt = existing.CreatedAt + } else { + entry.CreatedAt = a.now() + } + entry.UpdatedAt = a.now() if existing != nil && existing.ClaimStatus == types.ClaimStatusClaimed { entry.ClaimStatus = types.ClaimStatusClaimed diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go index d82240199..ba456e1f4 100644 --- a/bridgetracker/activity_test.go +++ b/bridgetracker/activity_test.go @@ -386,3 +386,52 @@ func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { require.Equal(t, claim, entries[0].Claim) require.Equal(t, 2, claims.isClaimedCalls, "the address was forgotten, so isClaimed is asked again from scratch") } + +// TestActivityCache_TimestampsTrackCreationAndLastUpdate verifies CreatedAt is stamped once and +// never changes, while UpdatedAt advances on every recheck of a still-unsettled entry. +func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + t1 := time.Now() + cache.now = func() time.Time { return t1 } + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].CreatedAt.Equal(t1)) + require.True(t, entries[0].UpdatedAt.Equal(t1)) + + t2 := t1.Add(time.Minute) + cache.now = func() time.Time { return t2 } + + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].CreatedAt.Equal(t1), "creation time must not change") + require.True(t, entries[0].UpdatedAt.Equal(t2), "update time must advance on every recheck") +} + +// TestActivityCache_TimestampsFreezeOnceSettled verifies UpdatedAt stops advancing once a bridge +// settles (claimed with its claim record fetched), since a settled entry is never refreshed again. +func TestActivityCache_TimestampsFreezeOnceSettled(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + // a single isClaimed/claimInfo entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + t1 := time.Now() + cache.now = func() time.Time { return t1 } + + entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].UpdatedAt.Equal(t1)) + + cache.now = func() time.Time { return t1.Add(time.Minute) } + + entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].UpdatedAt.Equal(t1), "a settled entry is never refreshed again") +} diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go index af93fbac6..1a14490e6 100644 --- a/bridgetracker/api/activity_command.go +++ b/bridgetracker/api/activity_command.go @@ -41,6 +41,13 @@ type ActivityItem struct { // Claim is the raw claim record, exactly as returned by the destination network's bridge // service, unmodified, once Claimed is true and the indexer has recorded it Claim *bridgeservicetypes.ClaimResponse `json:"claim,omitempty"` + // CreationTimestamp is when this bridge was first cached by the activity endpoint (unix + // seconds); it never changes after that + CreationTimestamp uint64 `json:"creation_timestamp"` + // LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix + // seconds), whether or not anything about it actually changed. Stops advancing once the + // bridge is claimed with its claim record fetched — nothing left to recheck + LastUpdatedTimestamp uint64 `json:"last_updated_timestamp"` // Tracking is the bridge tracker's current status for this bridge; only present when the // request set includeTracking=true and the bridge is still unclaimed Tracking *TrackingData `json:"tracking,omitempty"` @@ -111,10 +118,12 @@ func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { items := make([]ActivityItem, 0, len(entries)) for _, e := range entries { item := ActivityItem{ - Bridge: e.Bridge, - BridgeNetworkID: e.Bridge.OriginNetwork, - Claimed: e.ClaimStatus.String(), - Errors: e.Errors, + Bridge: e.Bridge, + BridgeNetworkID: e.Bridge.OriginNetwork, + Claimed: e.ClaimStatus.String(), + Errors: e.Errors, + CreationTimestamp: uint64(e.CreatedAt.Unix()), + LastUpdatedTimestamp: uint64(e.UpdatedAt.Unix()), } if e.Claim != nil { item.Claim = e.Claim diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index 9d99b9540..67880edbf 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -218,6 +218,10 @@ const docTemplate = `{ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, "errors": { "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", "type": "object", @@ -225,6 +229,10 @@ const docTemplate = `{ "type": "string" } }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 3bfdcc857..66daea90c 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -211,6 +211,10 @@ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, "errors": { "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", "type": "object", @@ -218,6 +222,10 @@ "type": "string" } }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index 24275c4ac..20749b3d7 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -30,6 +30,11 @@ definitions: "error" if the check itself failed (e.g. no bridge contract address configured for the destination network) — callers must not read "error" as "false" type: string + creation_timestamp: + description: |- + CreationTimestamp is when this bridge was first cached by the activity endpoint (unix + seconds); it never changes after that + type: integer errors: additionalProperties: type: string @@ -37,6 +42,12 @@ definitions: Errors holds the message of whatever check failed the last time this item was refreshed, keyed by which check it was — currently only "claim", present when Claimed is "error" type: object + last_updated_timestamp: + description: |- + LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix + seconds), whether or not anything about it actually changed. Stops advancing once the + bridge is claimed with its claim record fetched — nothing left to recheck + type: integer tracking: allOf: - $ref: '#/definitions/api.TrackingData' diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 21b47791d..127f6d398 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -390,6 +390,8 @@ func TestActivityHandlerHappyPath(t *testing.T) { require.Equal(t, bridge.OriginNetwork, body.Bridges[0].BridgeNetworkID) require.Equal(t, claim.TxHash, body.Bridges[0].Claim.TxHash) require.Equal(t, bridge.DestinationNetwork, body.Bridges[0].ClaimNetworkID) + require.NotZero(t, body.Bridges[0].CreationTimestamp) + require.NotZero(t, body.Bridges[0].LastUpdatedTimestamp) } // TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage verifies a failed isClaimed() diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go index 8405e6d9e..010c181b4 100644 --- a/bridgetracker/domain/activity.go +++ b/bridgetracker/domain/activity.go @@ -2,6 +2,7 @@ package domain import ( "context" + "time" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/types" @@ -30,6 +31,13 @@ type ActivityEntry struct { // refreshed, keyed by which check it was — currently only "claim", set when ClaimStatus is // Error (the isClaimed() check itself failed). nil while nothing has failed Errors map[string]string + // CreatedAt is when this bridge was first cached (its first successful refresh); it never + // changes after that + CreatedAt time.Time + // UpdatedAt is when this entry's claim/tracking state was last (re)computed — the last time + // refresh ran for it, whether or not anything about it actually changed. Frozen once the + // entry settles (see ActivityCache's settled), since a settled entry is never refreshed again + UpdatedAt time.Time } // ActivityBridgeScanner is the driven port to the raw bridge-service data behind the diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 3bfdcc857..66daea90c 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -211,6 +211,10 @@ "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", "type": "string" }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, "errors": { "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", "type": "object", @@ -218,6 +222,10 @@ "type": "string" } }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, "tracking": { "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", "allOf": [ diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index d6361eb7e..c3d118f31 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -1,6 +1,7 @@ # API The API is going to be an API REST: GET /tracker/v1/network/{network_id}/tx/{tx_hash} +GET /tracker/v1/activity/from/{from_address} GET /tracker/v1/health In addition to the REST endpoint, a WebSocket endpoint is provided to receive bridge status updates as they happen (see [WebSocket](#websocket)). @@ -331,6 +332,196 @@ Example: } ``` +## Activity + +GET /tracker/v1/activity/from/{from_address} + +Answers "what bridges has this address sent, and what is their claim state" across **every +bridge service the tracker currently knows about** (via the bridge service finder), instead of +one network/tx at a time like the main endpoint. Results are cached per `from_address` (see +[Caching and eviction](#caching-and-eviction) below). + +Request: + +| param | location | type | mandatory | desc | +| ------|----------|------|-----------|------| +| from_address | path | Address | yes | address that sent the bridges to look up | +| includeTracking | query | bool | no | `true` additionally registers every still-unclaimed bridge in the result with the bridge tracker (same effect as calling the main endpoint for it) and includes its current [TrackingData](#trackingdata) snapshot. Default `false` | +| filterBridges | query | string | no | one of `"all"` (default), `"claimed"`, `"pending"`, `"error"` — restricts the result to bridges with only that `claimed` state | + +### Behavior + +- `200 OK` — the body is an [ActivityResponse](#activityresponse). +- `400 Bad Request` — invalid `from_address`, or an unrecognized `filterBridges` value: the body is an [ErrorData](#errordata). +- `500 Internal Server Error` — scanning the configured bridge services failed: the body is an [ErrorData](#errordata). +- **This endpoint is opt-in**: it only exists if the binary is configured with both an activity bridge scanner and claim checker (`Config.ActivityScanner`/`ActivityClaims`); otherwise the route is not registered at all (plain `404`). +- Requesting `filterBridges=pending` or `filterBridges=error` **skips fetching the claim record** of a bridge found to be claimed, since it would be filtered out of that result anyway — its cache entry simply has no `claim` yet, and is fetched normally the next time `filterBridges=all`/`claimed` is used for that address. + +### ActivityResponse + +| field | type | desc | +| ------|------|------| +| from_address | Address | the address requested | +| bridges | ActivityItem [] | every bridge found for `from_address`, across every configured bridge service, matching `filterBridges` | + +### ActivityItem + +`bridge` and `claim` are the bridge service's own response shapes, reported **exactly as-is** +(see [BridgeResponse](#bridgeresponse) / [ClaimResponse](#claimresponse) below) — this endpoint +is a cache over that data, not a reinterpretation of it. `bridge_network_id`/`claim_network_id` +sit alongside them (not nested inside) so the caller knows which bridge service produced each one. + +| field | type | desc | +| ------|------|------| +| bridge | BridgeResponse | raw bridge event, exactly as returned by the origin network's bridge service | +| bridge_network_id | uint32 | network whose bridge service reported `bridge` (its origin network) | +| claimed | string | bare string, tri-state result of the destination bridge contract's `isClaimed()` call the last time it was checked: `"false"` (confirmed unclaimed), `"true"` (claimed), or `"error"` if the check itself failed (e.g. no bridge contract address configured for the destination network) — callers must **not** read `"error"` as `"false"` | +| claim_network_id | uint32 | network whose bridge service reported `claim` (the bridge's destination network); **omitted** (no key) until `claim` is present | +| claim | ClaimResponse | raw claim record, exactly as returned by the destination network's bridge service, once `claimed` is `"true"` and the indexer has recorded it; **omitted** (no key) until then | +| creation_timestamp | uint64 | unix seconds; when this bridge was first cached by this endpoint — never changes after that | +| last_updated_timestamp | uint64 | unix seconds; when this item's claim/tracking state was last (re)checked, whether or not anything about it actually changed. Stops advancing once the bridge is claimed with its claim record fetched, since it is never rechecked again from that point on | +| tracking | TrackingData | the bridge tracker's current status for this bridge (see [TrackingData](#trackingdata)); **omitted** (no key) unless the request set `includeTracking=true` and the bridge is still unclaimed | +| errors | map[string]string | message of whatever check failed the last time this item was refreshed, keyed by which check it was — currently only `"claim"`, present only when `claimed` is `"error"`. **Omitted** (no key) while nothing has failed | + +### BridgeResponse + +Exactly as returned by the origin network's own bridge service (`GET /bridge/v1/bridges`); not reinterpreted. + +| field | type | desc | +| ------|------|------| +| block_num | uint64 | block number where the bridge event was recorded | +| block_pos | uint64 | position of the bridge event within the block | +| from_address | Address | address that initiated the transaction on the bridge contract; may be absent | +| tx_hash | Hash | hash of the transaction that included the bridge event | +| global_index | string | global index of the bridge event (mainnet flag + rollup id + deposit count), serialized as a decimal string | +| block_timestamp | uint64 | timestamp of the block containing the bridge event | +| leaf_type | uint8 | 0 -> asset, 1 -> message | +| origin_network | uint32 | network where the bridge transaction originated | +| origin_address | Address | address of the token/sender on the origin network | +| destination_network | uint32 | network the bridge transaction is destined to | +| destination_address | Address | address of the receiver on the destination network | +| amount | string | amount being bridged, as a decimal string | +| metadata | string | optional metadata attached to the bridge event | +| deposit_count | uint32 | deposit index in the origin exit tree | +| bridge_hash | Hash | unique hash identifying the bridge event | +| txn_sender | Address | address that sent the transaction | +| to_address | Address | recipient contract of the transaction (may differ from the bridge contract) | + +### ClaimResponse + +Exactly as returned by the destination network's own bridge service (`GET /bridge/v1/claims`); not reinterpreted. + +| field | type | desc | +| ------|------|------| +| block_num | uint64 | block number where the claim was processed | +| block_timestamp | uint64 | timestamp of the block containing the claim | +| tx_hash | Hash | transaction hash of the claim | +| global_index | string | global index of the claim, as a decimal string | +| origin_address | Address | address initiating the claim on the origin network | +| origin_network | uint32 | origin network id | +| destination_address | Address | address receiving the claim on the destination network | +| destination_network | uint32 | destination network id | +| amount | string | amount claimed, as a decimal string | +| from_address | Address | address the claim originated from | +| mainnet_exit_root | Hash | mainnet exit root associated with the claim | +| rollup_exit_root | Hash | rollup exit root associated with the claim | +| global_exit_root | Hash | global exit root associated with the claim | +| proof_local_exit_root | Proof | local exit root proof; **omitted** (no key) unless the bridge service was asked to include proofs | +| proof_rollup_exit_root | Proof | rollup exit root proof; **omitted** (no key) unless the bridge service was asked to include proofs | +| metadata | string | metadata associated with the claim | +| is_message | bool | `true` for a message claim (leaf type 1), `false` for an asset claim | + +Example (one claimed bridge, one still-pending bridge with `?includeTracking=true`): + +```json +{ + "from_address": "0x1111111111111111111111111111111111111111", + "bridges": [ + { + "bridge": { + "block_num": 1000, + "block_pos": 0, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "global_index": "4294967296", + "block_timestamp": 1700000000, + "leaf_type": 0, + "origin_network": 1, + "origin_address": "0x0000000000000000000000000000000000000020", + "destination_network": 2, + "destination_address": "0x0000000000000000000000000000000000000030", + "amount": "100", + "metadata": "0x", + "deposit_count": 7, + "bridge_hash": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "txn_sender": "0x1111111111111111111111111111111111111111", + "to_address": "0x0000000000000000000000000000000000000030" + }, + "bridge_network_id": 1, + "claimed": "true", + "claim_network_id": 2, + "claim": { + "block_num": 1050, + "block_timestamp": 1700003600, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002", + "global_index": "4294967296", + "origin_address": "0x0000000000000000000000000000000000000020", + "origin_network": 1, + "destination_address": "0x0000000000000000000000000000000000000030", + "destination_network": 2, + "amount": "100", + "from_address": "0x1111111111111111111111111111111111111111", + "mainnet_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000010", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000011", + "global_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000012", + "metadata": "0x", + "is_message": false + }, + "creation_timestamp": 1700000100, + "last_updated_timestamp": 1700003700 + }, + { + "bridge": { + "block_num": 1200, + "block_pos": 1, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000003", + "global_index": "4294967297", + "block_timestamp": 1700010000, + "leaf_type": 0, + "origin_network": 1, + "origin_address": "0x0000000000000000000000000000000000000020", + "destination_network": 2, + "destination_address": "0x0000000000000000000000000000000000000030", + "amount": "50", + "metadata": "0x", + "deposit_count": 8, + "bridge_hash": "0x0000000000000000000000000000000000000000000000000000000000000def", + "txn_sender": "0x1111111111111111111111111111111111111111", + "to_address": "0x0000000000000000000000000000000000000030" + }, + "bridge_network_id": 1, + "claimed": "false", + "creation_timestamp": 1700010100, + "last_updated_timestamp": 1700010100, + "tracking": { + "tracking_status": "running", + "network_id": 1, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000003", + "bridge_status": null, + "step_index": null, + "all_steps": null, + "error": null + } + } + ] +} +``` + +### Caching and eviction + +- **Opt-in and always-on caching**: the endpoint only exists when configured (see [Behavior](#behavior-1) above); when it does, results for a given `from_address` are cached in memory across calls: a bridge already confirmed claimed, with its claim record already fetched, is never re-verified again. Every other bridge (new, still unclaimed, claimed but not yet indexed, or errored) is rechecked on every call — without re-walking already-scanned pages of the underlying bridge services, since each network's scan stops as soon as it reaches a bridge already in the cache. +- **Idle eviction**: a `from_address` nobody has asked about in `Tracker.ActivityIdleTimeout` (default 30 minutes, same idea as the main endpoint's `IdleTimeout`) is forgotten entirely on the next request for it — everything cached for it (bridges, claim state) is freed, and it starts fresh exactly as if it were being queried for the first time. +- **`includeTracking=true` registers, it does not wait**: unlike the main tracker endpoint, this does not wait for the tracking engine's first resolution attempt — it registers the bridge (if not already registered) and reports whatever `TrackingData` snapshot is available right away, which may still be the bare `"registered"` state. + ## WebSocket Endpoint to subscribe to a bridge and receive its status updates as they happen, instead of polling the REST endpoint. From 7c1afb1f33231cb00b296dfcf92a1f282701e494 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:31:41 +0200 Subject: [PATCH 09/16] feat(bridgetracker): claimed step resolver, bridge-address endpoint, block timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StepClaimed now gets its own resolver (ClaimedResolver) fetching the claim tx/block from the destination bridge service, decoupled from StepWaitingClaim, which now checks isClaimed() on-chain directly via the new ClaimChecker port instead of waiting on the claim record. - Factor the on-chain isClaimed() binding/cache logic out of ActivitySource into sources/claim_checker.go (contractClaimCheckers), shared by both the tracker engine's ClaimChecker and the activity endpoint's ActivitySource. - Introduce domain.ScannedBridge to track which network's bridge service actually reported a scanned bridge (NetworkID), distinct from Bridge.OriginNetwork (the bridged asset's origin network) — they diverge for a re-bridged asset across more than one hop, which was feeding the wrong sourceBridgeNetwork into isClaimed() and the wrong network into TrackingID for such bridges. - Add GET /bridge-address[/{network_id}], resolving the bridge contract address for one network or every network currently known (opt-in via Config.BridgeAddressResolver; wired in proxy/cmd/run.go off bridgeservicefinder.Finder). - ClaimResult and InjectedGERResult now also carry BlockTimestamp alongside BlockNumber. - Regenerate swagger docs and update API.md accordingly. --- bridgetracker/activity.go | 22 +-- bridgetracker/activity_test.go | 127 +++++++++------ bridgetracker/api/activity_command.go | 6 +- bridgetracker/api/api.go | 18 ++- bridgetracker/api/bridge_address_command.go | 88 +++++++++++ bridgetracker/api/bridge_step_path.go | 2 +- bridgetracker/api/bridge_step_path_test.go | 9 +- bridgetracker/api/docs/docs.go | 112 +++++++++++-- bridgetracker/api/docs/swagger.json | 112 +++++++++++-- bridgetracker/api/docs/swagger.yaml | 89 +++++++++-- bridgetracker/bridge_address_test.go | 149 ++++++++++++++++++ bridgetracker/bridgetracker.go | 3 +- bridgetracker/bridgetracker_test.go | 12 +- bridgetracker/config.go | 6 + bridgetracker/domain/activity.go | 24 ++- bridgetracker/domain/bridge_address.go | 20 +++ bridgetracker/domain/bridge_step_path.go | 2 +- bridgetracker/domain/resolve_step_claimed.go | 45 ++++++ .../domain/resolve_step_waiting_claim.go | 30 ++-- .../resolve_step_waiting_ger_injection.go | 9 +- bridgetracker/domain/resolve_steps.go | 17 +- bridgetracker/domain/resolve_steps_test.go | 59 ++++--- bridgetracker/engine.go | 6 +- bridgetracker/engine_test.go | 36 ++++- bridgetracker/mocks/mock_claim_checker.go | 95 +++++++++++ bridgetracker/ports.go | 16 +- bridgetracker/sources/activity.go | 99 ++++-------- bridgetracker/sources/activity_test.go | 28 ++-- bridgetracker/sources/claim.go | 5 +- bridgetracker/sources/claim_checker.go | 108 +++++++++++++ bridgetracker/sources/ger.go | 14 +- bridgetracker/sources/sources_test.go | 20 ++- bridgetracker/types/status.go | 25 +-- .../swagger/bridge_tracker/swagger.json | 112 +++++++++++-- docs/bridgetracker/API.md | 67 +++++++- proxy/cmd/run.go | 6 + 36 files changed, 1317 insertions(+), 281 deletions(-) create mode 100644 bridgetracker/api/bridge_address_command.go create mode 100644 bridgetracker/bridge_address_test.go create mode 100644 bridgetracker/domain/bridge_address.go create mode 100644 bridgetracker/domain/resolve_step_claimed.go create mode 100644 bridgetracker/mocks/mock_claim_checker.go create mode 100644 bridgetracker/sources/claim_checker.go diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go index d1d11915a..bfa8695f9 100644 --- a/bridgetracker/activity.go +++ b/bridgetracker/activity.go @@ -6,7 +6,6 @@ import ( "sync" "time" - bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/domain" "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" @@ -97,7 +96,8 @@ func (a *ActivityCache) GetActivity( a.mu.Unlock() for _, entry := range cached { - a.upsert(ctx, addrCache, entry.Bridge, includeTracking, filter) + scanned := &domain.ScannedBridge{Bridge: entry.Bridge, NetworkID: entry.BridgeNetworkID} + a.upsert(ctx, addrCache, scanned, includeTracking, filter) } newItems, err := a.scanner.BridgesFrom(ctx, fromAddress, known) @@ -124,10 +124,10 @@ func (a *ActivityCache) GetActivity( // sure is genuinely new (e.g. a defensive re-check, or a pagination-boundary duplicate): settled // entries are never redundantly refreshed regardless of where item came from func (a *ActivityCache) upsert( - ctx context.Context, addrCache *activityAddrCache, item *bridgeservicetypes.BridgeResponse, + ctx context.Context, addrCache *activityAddrCache, item *domain.ScannedBridge, includeTracking bool, filter types.ActivityFilter, ) { - key := item.GlobalIndex.String() + key := item.Bridge.GlobalIndex.String() a.mu.Lock() existing := addrCache.entries[key] @@ -223,10 +223,10 @@ func settled(entry *domain.ActivityEntry) bool { // failure at any step is logged and left for the next call to retry; it never fails the whole // GetActivity call, since one bad network should not hide every other bridge found func (a *ActivityCache) refresh( - ctx context.Context, item *bridgeservicetypes.BridgeResponse, existing *domain.ActivityEntry, + ctx context.Context, item *domain.ScannedBridge, existing *domain.ActivityEntry, includeTracking bool, filter types.ActivityFilter, ) *domain.ActivityEntry { - entry := &domain.ActivityEntry{Bridge: item} + entry := &domain.ActivityEntry{Bridge: item.Bridge, BridgeNetworkID: item.NetworkID} if existing != nil { entry.CreatedAt = existing.CreatedAt } else { @@ -239,8 +239,8 @@ func (a *ActivityCache) refresh( } else { claimed, err := a.claims.IsClaimed(ctx, item) if err != nil { - a.logger.Warnf("activity: checking claim state of bridge tx=%s (origin network=%d, deposit=%d): %v", - item.TxHash, item.OriginNetwork, item.DepositCount, err) + a.logger.Warnf("activity: checking claim state of bridge tx=%s (network=%d, deposit=%d): %v", + item.Bridge.TxHash, item.NetworkID, item.Bridge.DepositCount, err) entry.ClaimStatus = types.ClaimStatusError entry.Errors = map[string]string{"claim": err.Error()} return entry @@ -258,17 +258,17 @@ func (a *ActivityCache) refresh( } claim, err := a.claims.ClaimInfo(ctx, item) if err != nil { - a.logger.Warnf("activity: fetching claim record of bridge tx=%s: %v", item.TxHash, err) + a.logger.Warnf("activity: fetching claim record of bridge tx=%s: %v", item.Bridge.TxHash, err) } entry.Claim = claim return entry } if includeTracking { - id := domain.TrackingID{NetworkID: item.OriginNetwork, TxHash: common.HexToHash(string(item.TxHash))} + id := domain.TrackingID{NetworkID: item.NetworkID, TxHash: common.HexToHash(string(item.Bridge.TxHash))} tracking, err := a.supervised.Get(id, true) if err != nil { - a.logger.Warnf("activity: registering bridge tx=%s with the tracker: %v", item.TxHash, err) + a.logger.Warnf("activity: registering bridge tx=%s with the tracker: %v", item.Bridge.TxHash, err) } else { entry.Tracking = tracking } diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go index ba456e1f4..7fa4754ed 100644 --- a/bridgetracker/activity_test.go +++ b/bridgetracker/activity_test.go @@ -17,6 +17,11 @@ import ( var testFromAddress = common.HexToAddress("0x1111111111111111111111111111111111111111") +// testScannedNetworkID is the network these tests' bridges were scanned from, matching +// testBridge's hardcoded OriginNetwork so most tests need not care about the distinction (see +// TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork for a test that does) +const testScannedNetworkID = uint32(1) + func testBridge(globalIndex int64) *bridgeservicetypes.BridgeResponse { return &bridgeservicetypes.BridgeResponse{ OriginNetwork: 1, @@ -27,11 +32,21 @@ func testBridge(globalIndex int64) *bridgeservicetypes.BridgeResponse { } } +// testScannedBridge wraps testBridge with testScannedNetworkID, the network these tests treat +// as "the bridge service that returned it" +func testScannedBridge(globalIndex int64) *domain.ScannedBridge { + return scannedBridge(testBridge(globalIndex), testScannedNetworkID) +} + +func scannedBridge(bridge *bridgeservicetypes.BridgeResponse, networkID uint32) *domain.ScannedBridge { + return &domain.ScannedBridge{Bridge: bridge, NetworkID: networkID} +} + // fakeActivityScanner is a hand-rolled ActivityBridgeScanner for tests: it returns whichever of // bridges is not in known, mirroring ActivitySource.BridgesFrom's real contract. calls records // how many times it was invoked, lastKnown the known argument it was last called with. type fakeActivityScanner struct { - bridges []*bridgeservicetypes.BridgeResponse + bridges []*domain.ScannedBridge err error calls int lastKnown map[string]struct{} @@ -39,15 +54,15 @@ type fakeActivityScanner struct { func (f *fakeActivityScanner) BridgesFrom( _ context.Context, _ common.Address, known map[string]struct{}, -) ([]*bridgeservicetypes.BridgeResponse, error) { +) ([]*domain.ScannedBridge, error) { f.calls++ f.lastKnown = known if f.err != nil { return nil, f.err } - out := make([]*bridgeservicetypes.BridgeResponse, 0, len(f.bridges)) + out := make([]*domain.ScannedBridge, 0, len(f.bridges)) for _, b := range f.bridges { - if _, ok := known[b.GlobalIndex.String()]; ok { + if _, ok := known[b.Bridge.GlobalIndex.String()]; ok { continue } out = append(out, b) @@ -59,16 +74,19 @@ func (f *fakeActivityScanner) BridgesFrom( // consulted in FIFO order per call, one entry per expected IsClaimed/ClaimInfo invocation, so a // test can assert exactly how many times each was called (and fail loudly if called more). // isClaimedErrs, if non-nil, is consulted alongside isClaimed: a non-nil entry makes that call -// fail instead of returning the paired isClaimed value. +// fail instead of returning the paired isClaimed value. lastIsClaimedNetworkID records the +// NetworkID of the last ScannedBridge IsClaimed was called with. type fakeActivityClaims struct { - isClaimed []bool - isClaimedErrs []error - isClaimedCalls int - claimInfo []*bridgeservicetypes.ClaimResponse - claimInfoCalls int + isClaimed []bool + isClaimedErrs []error + isClaimedCalls int + lastIsClaimedNetworkID uint32 + claimInfo []*bridgeservicetypes.ClaimResponse + claimInfoCalls int } -func (f *fakeActivityClaims) IsClaimed(context.Context, *bridgeservicetypes.BridgeResponse) (bool, error) { +func (f *fakeActivityClaims) IsClaimed(_ context.Context, bridge *domain.ScannedBridge) (bool, error) { + f.lastIsClaimedNetworkID = bridge.NetworkID i := f.isClaimedCalls f.isClaimedCalls++ if i < len(f.isClaimedErrs) && f.isClaimedErrs[i] != nil { @@ -78,7 +96,7 @@ func (f *fakeActivityClaims) IsClaimed(context.Context, *bridgeservicetypes.Brid } func (f *fakeActivityClaims) ClaimInfo( - context.Context, *bridgeservicetypes.BridgeResponse, + context.Context, *domain.ScannedBridge, ) (*bridgeservicetypes.ClaimResponse, error) { claim := f.claimInfo[f.claimInfoCalls] f.claimInfoCalls++ @@ -96,8 +114,7 @@ func newTestActivityCache(scanner ActivityBridgeScanner, claims ActivityClaimChe // state is re-verified on every GetActivity call, and that includeTracking=false never // registers it with the tracker. func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { - bridge := testBridge(1) - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} claims := &fakeActivityClaims{isClaimed: []bool{false, false}} cache := newTestActivityCache(scanner, claims) @@ -116,10 +133,10 @@ func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { // TestActivityCache_IncludeTrackingRegistersUnclaimedBridge verifies includeTracking=true // registers a still-unclaimed bridge with the supervised store (register-only) and reports its -// snapshot. +// snapshot, keyed by the scanned network — not Bridge.OriginNetwork. func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { bridge := testBridge(1) - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}} claims := &fakeActivityClaims{isClaimed: []bool{false}} cache := newTestActivityCache(scanner, claims) @@ -130,16 +147,15 @@ func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) require.NotNil(t, entries[0].Tracking) - wantID := domain.TrackingID{NetworkID: bridge.OriginNetwork, TxHash: common.HexToHash(string(bridge.TxHash))} + wantID := domain.TrackingID{NetworkID: testScannedNetworkID, TxHash: common.HexToHash(string(bridge.TxHash))} require.Equal(t, wantID, entries[0].Tracking.ID()) } // TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked verifies a bridge that is claimed // with its claim record already fetched is never rechecked on a later call. func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { - bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} // only one IsClaimed/ClaimInfo entry: a second consultation would panic on out-of-range claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} @@ -163,9 +179,8 @@ func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { // returns nil), has its claim record retried on the next call — without asking isClaimed() again, // since a confirmed claim never reverts (see ActivityCache.refresh). func TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried(t *testing.T) { - bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} // a single isClaimed entry: a second consultation would panic on out-of-range, proving it is // never asked again once confirmed claimed claims := &fakeActivityClaims{ @@ -204,8 +219,7 @@ func TestActivityCache_ScannerErrorFailsTheCall(t *testing.T) { // ClaimStatusError — never silently as ClaimStatusUnclaimed — and is retried on the next call // (unlike a confirmed claim, an error is not permanent). func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { - bridge := testBridge(1) - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} claims := &fakeActivityClaims{ isClaimed: []bool{false, false}, isClaimedErrs: []error{errors.New("no bridge contract address configured for network 2"), nil}, @@ -232,11 +246,9 @@ func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { // and ones whose isClaimed() check errored — and never fetches a claimed bridge's claim record // (only IsClaimed is consulted, never ClaimInfo). func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t *testing.T) { - claimedBridge := testBridge(1) - pendingBridge := testBridge(2) - erroredBridge := testBridge(3) + pendingBridge := testScannedBridge(2) scanner := &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge, erroredBridge}, + bridges: []*domain.ScannedBridge{testScannedBridge(1), pendingBridge, testScannedBridge(3)}, } claims := &fakeActivityClaims{ isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called @@ -248,7 +260,7 @@ func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) require.NoError(t, err) require.Len(t, entries, 1) - require.Equal(t, pendingBridge, entries[0].Bridge) + require.Equal(t, pendingBridge.Bridge, entries[0].Bridge) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) require.Equal(t, 0, claims.claimInfoCalls) } @@ -257,12 +269,10 @@ func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t // returns only bridges whose isClaimed() check failed, excludes claimed and pending ones, and // never fetches a claimed bridge's claim record for this filter either. func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing.T) { - claimedBridge := testBridge(1) - pendingBridge := testBridge(2) - erroredBridge := testBridge(3) + erroredBridge := testScannedBridge(3) wantErr := errors.New("boom") scanner := &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge, erroredBridge}, + bridges: []*domain.ScannedBridge{testScannedBridge(1), testScannedBridge(2), erroredBridge}, } claims := &fakeActivityClaims{ isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called @@ -274,7 +284,7 @@ func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterError) require.NoError(t, err) require.Len(t, entries, 1) - require.Equal(t, erroredBridge, entries[0].Bridge) + require.Equal(t, erroredBridge.Bridge, entries[0].Bridge) require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) require.Equal(t, wantErr.Error(), entries[0].Errors["claim"]) require.Equal(t, 0, claims.claimInfoCalls) @@ -283,13 +293,11 @@ func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing // TestActivityCache_FilterClaimedExcludesPending verifies ActivityFilterClaimed returns only // confirmed-claimed bridges, excluding both unclaimed ones and ones whose check errored. func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { - claimedBridge := testBridge(1) - unclaimedBridge := testBridge(2) - erroredBridge := testBridge(3) + claimedBridge := testScannedBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} scanner := &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, unclaimedBridge, erroredBridge}, + bridges: []*domain.ScannedBridge{claimedBridge, testScannedBridge(2), testScannedBridge(3)}, } claims := &fakeActivityClaims{ isClaimed: []bool{true, false, false}, @@ -302,7 +310,7 @@ func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterClaimed) require.NoError(t, err) require.Len(t, entries, 1) - require.Equal(t, claimedBridge, entries[0].Bridge) + require.Equal(t, claimedBridge.Bridge, entries[0].Bridge) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Equal(t, claim, entries[0].Claim) } @@ -312,9 +320,8 @@ func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { // gets its claim record fetched normally the next time ActivityFilterAll is used — without // isClaimed() being asked again, since it was already confirmed claimed. func TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed(t *testing.T) { - bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} // a single isClaimed entry: a second consultation would panic on out-of-range claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} @@ -341,8 +348,8 @@ func TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed(t *tes // known set the first time (nothing cached yet), and with the previously found bridge's key once // it has been cached. func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { - bridge := testBridge(1) - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + bridge := testScannedBridge(1) + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{bridge}} claims := &fakeActivityClaims{isClaimed: []bool{false, false}} cache := newTestActivityCache(scanner, claims) @@ -353,7 +360,7 @@ func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) - require.Contains(t, scanner.lastKnown, bridge.GlobalIndex.String()) + require.Contains(t, scanner.lastKnown, bridge.Bridge.GlobalIndex.String()) } // TestActivityCache_IdleAddressIsForgotten verifies an address untouched for longer than @@ -361,9 +368,8 @@ func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { // again for a bridge that had already settled — which would not happen if its cached state had // survived. func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { - bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} claims := &fakeActivityClaims{ isClaimed: []bool{true, true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim, claim}, @@ -390,8 +396,7 @@ func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { // TestActivityCache_TimestampsTrackCreationAndLastUpdate verifies CreatedAt is stamped once and // never changes, while UpdatedAt advances on every recheck of a still-unsettled entry. func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { - bridge := testBridge(1) - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} claims := &fakeActivityClaims{isClaimed: []bool{false, false}} cache := newTestActivityCache(scanner, claims) @@ -415,9 +420,8 @@ func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { // TestActivityCache_TimestampsFreezeOnceSettled verifies UpdatedAt stops advancing once a bridge // settles (claimed with its claim record fetched), since a settled entry is never refreshed again. func TestActivityCache_TimestampsFreezeOnceSettled(t *testing.T) { - bridge := testBridge(1) claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} - scanner := &fakeActivityScanner{bridges: []*bridgeservicetypes.BridgeResponse{bridge}} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} // a single isClaimed/claimInfo entry: a second consultation would panic on out-of-range claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} @@ -435,3 +439,28 @@ func TestActivityCache_TimestampsFreezeOnceSettled(t *testing.T) { require.NoError(t, err) require.True(t, entries[0].UpdatedAt.Equal(t1), "a settled entry is never refreshed again") } + +// TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork verifies +// ActivityEntry.BridgeNetworkID reflects the network the bridge was scanned from (what the +// caller actually asked bridge_service for), not Bridge.OriginNetwork — which is the origin of +// the bridged ASSET and can differ when re-bridging an asset from a third network (see +// domain.ScannedBridge). It also verifies isClaimed()'s on-chain sourceBridgeNetwork argument and +// the tracker's TrackingID both use the scanned network, never Bridge.OriginNetwork. +func TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork(t *testing.T) { + // OriginNetwork (99) is the asset-origin decoy, deliberately different from the network this + // bridge was actually scanned from (5) + const scannedNetworkID = uint32(7) + bridge := testBridge(1) + bridge.OriginNetwork = 99 + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{scannedBridge(bridge, scannedNetworkID)}} + claims := &fakeActivityClaims{isClaimed: []bool{false}} + + cache := newTestActivityCache(scanner, claims) + + entries, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, scannedNetworkID, entries[0].BridgeNetworkID) + require.Equal(t, scannedNetworkID, claims.lastIsClaimedNetworkID) + require.Equal(t, scannedNetworkID, entries[0].Tracking.ID().NetworkID) +} diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go index 1a14490e6..cc6950a2c 100644 --- a/bridgetracker/api/activity_command.go +++ b/bridgetracker/api/activity_command.go @@ -28,7 +28,9 @@ type ActivityItem struct { // Bridge is the raw bridge event, exactly as returned by the origin network's bridge // service, unmodified Bridge *bridgeservicetypes.BridgeResponse `json:"bridge"` - // BridgeNetworkID is the network whose bridge service reported Bridge (its origin network) + // BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily + // Bridge.OriginNetwork, which is the origin network of the bridged asset and can differ for + // a re-bridged asset (see domain.ScannedBridge) BridgeNetworkID uint32 `json:"bridge_network_id"` // Claimed is the tri-state result of the destination bridge contract's isClaimed() call // the last time it was checked: "false" (confirmed unclaimed), "true" (claimed), or @@ -119,7 +121,7 @@ func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { for _, e := range entries { item := ActivityItem{ Bridge: e.Bridge, - BridgeNetworkID: e.Bridge.OriginNetwork, + BridgeNetworkID: e.BridgeNetworkID, Claimed: e.ClaimStatus.String(), Errors: e.Errors, CreationTimestamp: uint64(e.CreatedAt.Unix()), diff --git a/bridgetracker/api/api.go b/bridgetracker/api/api.go index 9005ba2a8..0b1d5bee1 100644 --- a/bridgetracker/api/api.go +++ b/bridgetracker/api/api.go @@ -61,6 +61,10 @@ type API struct { // activityCmd serves GET /activity/from/{from_address}; nil (when NewAPI is given a nil // activity) leaves the route unregistered entirely — see RegisterRoutes activityCmd *activityCommand + // bridgeAddressCmd serves GET /bridge-address and GET /bridge-address/{network_id}; nil + // (when NewAPI is given a nil bridgeAddressResolver) leaves both routes unregistered + // entirely — see RegisterRoutes + bridgeAddressCmd *bridgeAddressCommand } // NewAPI returns the tracker HTTP service serving the given supervised registry. @@ -68,12 +72,15 @@ type API struct { // the tracking engine's immediate resolution attempt to produce an update before answering (see // getTxStatusCommand); <= 0 disables the wait. cors governs which origins may open the // WebSocket endpoint (see wsHandler). activity may be nil, in which case the -// GET /activity/from/{from_address} endpoint is not registered at all (see RegisterRoutes) +// GET /activity/from/{from_address} endpoint is not registered at all (see RegisterRoutes). +// bridgeAddressResolver may be nil, in which case neither GET /bridge-address nor +// GET /bridge-address/{network_id} is registered at all (see RegisterRoutes) func NewAPI( logger aggkitcommon.Logger, configSHA1 string, supervised domain.SupervisedRegistry, activity domain.ActivityQuerier, + bridgeAddressResolver domain.BridgeAddressResolver, registerResolveTimeout time.Duration, cors aggkitcommon.CORSConfig, ) *API { @@ -90,6 +97,9 @@ func NewAPI( if activity != nil { api.activityCmd = &activityCommand{querier: activity} } + if bridgeAddressResolver != nil { + api.bridgeAddressCmd = &bridgeAddressCommand{resolver: bridgeAddressResolver} + } return api } @@ -108,6 +118,12 @@ func (a *API) RegisterRoutes(router gin.IRouter) { trackerGroup.GET("/activity/from/:"+fromAddressParam, func(c *gin.Context) { runCommand(c, a.activityCmd) }) } + if a.bridgeAddressCmd != nil { + trackerGroup.GET("/bridge-address", + func(c *gin.Context) { runCommand(c, a.bridgeAddressCmd) }) + trackerGroup.GET("/bridge-address/:"+networkIDParam, + func(c *gin.Context) { runCommand(c, a.bridgeAddressCmd) }) + } // Swagger docs endpoint trackerGroup.GET("/swagger/*any", ginswagger.WrapHandler(swaggerfiles.Handler)) diff --git a/bridgetracker/api/bridge_address_command.go b/bridgetracker/api/bridge_address_command.go new file mode 100644 index 000000000..a3ec717cb --- /dev/null +++ b/bridgetracker/api/bridge_address_command.go @@ -0,0 +1,88 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +// compile-time check: bridgeAddressCommand fulfils the command interface +var _ command = (*bridgeAddressCommand)(nil) + +// bridgeAddressCommand answers GET /bridge-address and GET /bridge-address/{network_id}: with +// no network_id it reports the bridge contract address of every network the resolver currently +// knows about, and with one it reports only that network's +type bridgeAddressCommand struct { + resolver domain.BridgeAddressResolver +} + +// BridgeAddressItem is the bridge contract address of one network +type BridgeAddressItem struct { + // NetworkID is the network BridgeAddress belongs to + NetworkID uint32 `json:"network_id"` + // BridgeAddress is the bridge contract address on NetworkID + BridgeAddress common.Address `json:"bridge_address"` +} + +// BridgeAddressResponse is the body of GET /bridge-address +type BridgeAddressResponse struct { + // Bridges holds the bridge contract address of every network currently known + Bridges []BridgeAddressItem `json:"bridges"` +} + +// Execute implements command: with no network_id path parameter it resolves the bridge +// contract address of every network the resolver currently knows about (BridgeAddressResponse); +// with one it resolves only that network's (BridgeAddressItem). 200 OK unless: invalid +// network_id (ErrorData/400), or resolving the address failed (ErrorData/500) +// +// @Summary Get the bridge contract address of one network, or every network +// @Description With no network_id, reports the bridge contract address of every network the +// @Description tracker currently knows about (via the bridge service finder). With network_id, +// @Description reports only that network's. +// @Tags bridge-tracker +// @Produce json +// @Param network_id path int false "Network to look up; omit to get every network" +// @Success 200 {object} BridgeAddressResponse "Body when network_id is omitted" +// @Success 200 {object} BridgeAddressItem "Body when network_id is set" +// @Failure 400 {object} types.ErrorData "Invalid network_id" +// @Failure 500 {object} types.ErrorData "Resolving the bridge contract address failed" +// @Router /bridge-address [get] +// @Router /bridge-address/{network_id} [get] +func (cmd *bridgeAddressCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) { + networkIDStr := c.Param(networkIDParam) + if networkIDStr == "" { + return cmd.executeAll(c) + } + + networkID, err := strconv.ParseUint(networkIDStr, decimalBase, uint32BitSize) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: "invalid network_id parameter"} + } + + addr, err := cmd.resolver.BridgeAddress(c.Request.Context(), uint32(networkID)) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + + return http.StatusOK, BridgeAddressItem{NetworkID: uint32(networkID), BridgeAddress: addr}, nil +} + +// executeAll resolves the bridge contract address of every network cmd.resolver currently +// knows about (see domain.BridgeAddressResolver.NetworkIDs) +func (cmd *bridgeAddressCommand) executeAll(c *gin.Context) (int, any, *types.ErrorData) { + networkIDs := cmd.resolver.NetworkIDs() + items := make([]BridgeAddressItem, 0, len(networkIDs)) + for _, networkID := range networkIDs { + addr, err := cmd.resolver.BridgeAddress(c.Request.Context(), networkID) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + items = append(items, BridgeAddressItem{NetworkID: networkID, BridgeAddress: addr}) + } + + return http.StatusOK, BridgeAddressResponse{Bridges: items}, nil +} diff --git a/bridgetracker/api/bridge_step_path.go b/bridgetracker/api/bridge_step_path.go index c857d3164..1aed031d2 100644 --- a/bridgetracker/api/bridge_step_path.go +++ b/bridgetracker/api/bridge_step_path.go @@ -23,7 +23,7 @@ type BridgeStepPath struct { // (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), // *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData // (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - // *types.ClaimResult (StepWaitingClaim). nil until + // *types.ClaimResult (StepClaimed). nil until // the step produces one, and for steps that never do. Most steps only set this once Done, // but StepCertificatePending (Status still InProgress) may already carry the certificate's // current, not yet settled, status — see domain.ErrCertificateNotSettled diff --git a/bridgetracker/api/bridge_step_path_test.go b/bridgetracker/api/bridge_step_path_test.go index fe3d1dce9..163b2aaa8 100644 --- a/bridgetracker/api/bridge_step_path_test.go +++ b/bridgetracker/api/bridge_step_path_test.go @@ -135,9 +135,12 @@ func TestBridgeStepPathResultMarshalJSON(t *testing.T) { expected: `{"certificate_id":"0x0000000000000000000000000000000000000000000000000000000000000001","status":4,"status_string":"Settled"}`, }, { - name: "claim result", - result: &types.ClaimResult{ClaimTx: common.HexToHash("0x0c"), BlockNumber: 300}, - expected: `{"claim_tx":"0x000000000000000000000000000000000000000000000000000000000000000c","block_number":300}`, + name: "claim result", + result: &types.ClaimResult{ + ClaimTx: common.HexToHash("0x0c"), BlockNumber: 300, BlockTimestamp: 1700000000, + }, + expected: `{"claim_tx":"0x000000000000000000000000000000000000000000000000000000000000000c",` + + `"block_number":300,"block_timestamp":1700000000}`, }, { name: "L1 settled GER result", diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index 67880edbf..5d18a568b 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -82,6 +82,78 @@ const docTemplate = `{ } } }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -199,7 +271,7 @@ const docTemplate = `{ ] }, "bridge_network_id": { - "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", "type": "integer" }, "claim": { @@ -262,6 +334,34 @@ const docTemplate = `{ } } }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -348,7 +448,7 @@ const docTemplate = `{ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -445,16 +545,12 @@ const docTemplate = `{ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -487,16 +583,12 @@ const docTemplate = `{ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 66daea90c..85b8399b8 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -75,6 +75,78 @@ } } }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -192,7 +264,7 @@ ] }, "bridge_network_id": { - "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", "type": "integer" }, "claim": { @@ -255,6 +327,34 @@ } } }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -341,7 +441,7 @@ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -438,16 +538,12 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -480,16 +576,12 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index 20749b3d7..aa51a7757 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -9,8 +9,10 @@ definitions: Bridge is the raw bridge event, exactly as returned by the origin network's bridge service, unmodified bridge_network_id: - description: BridgeNetworkID is the network whose bridge service reported - Bridge (its origin network) + description: |- + BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily + Bridge.OriginNetwork, which is the origin network of the bridged asset and can differ for + a re-bridged asset (see domain.ScannedBridge) type: integer claim: allOf: @@ -69,6 +71,26 @@ definitions: type: integer type: array type: object + api.BridgeAddressItem: + properties: + bridge_address: + description: BridgeAddress is the bridge contract address on NetworkID + items: + type: integer + type: array + network_id: + description: NetworkID is the network BridgeAddress belongs to + type: integer + type: object + api.BridgeAddressResponse: + properties: + bridges: + description: Bridges holds the bridge contract address of every network currently + known + items: + $ref: '#/definitions/api.BridgeAddressItem' + type: array + type: object api.BridgeEventData: properties: amount: @@ -145,7 +167,7 @@ definitions: (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - *types.ClaimResult (StepWaitingClaim). nil until + *types.ClaimResult (StepClaimed). nil until the step produces one, and for steps that never do. Most steps only set this once Done, but StepCertificatePending (Status still InProgress) may already carry the certificate's current, not yet settled, status — see domain.ErrCertificateNotSettled @@ -237,16 +259,12 @@ definitions: - 1000000000 - 60000000000 - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - 1 - 1000 - 1000000 - 1000000000 - 60000000000 - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - 1 - 1000 - 1000000 @@ -280,16 +298,12 @@ definitions: - Second - Minute - Hour - - minDuration - - maxDuration - Nanosecond - Microsecond - Millisecond - Second - Minute - Hour - - minDuration - - maxDuration - Nanosecond - Microsecond - Millisecond @@ -600,6 +614,59 @@ paths: summary: Get bridge activity by sender address tags: - bridge-tracker + /bridge-address: + get: + description: |- + With no network_id, reports the bridge contract address of every network the + tracker currently knows about (via the bridge service finder). With network_id, + reports only that network's. + produces: + - application/json + responses: + "200": + description: Body when network_id is set + schema: + $ref: '#/definitions/api.BridgeAddressItem' + "400": + description: Invalid network_id + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Resolving the bridge contract address failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get the bridge contract address of one network, or every network + tags: + - bridge-tracker + /bridge-address/{network_id}: + get: + description: |- + With no network_id, reports the bridge contract address of every network the + tracker currently knows about (via the bridge service finder). With network_id, + reports only that network's. + parameters: + - description: Network to look up; omit to get every network + in: path + name: network_id + type: integer + produces: + - application/json + responses: + "200": + description: Body when network_id is set + schema: + $ref: '#/definitions/api.BridgeAddressItem' + "400": + description: Invalid network_id + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Resolving the bridge contract address failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get the bridge contract address of one network, or every network + tags: + - bridge-tracker /health: get: description: |- diff --git a/bridgetracker/bridge_address_test.go b/bridgetracker/bridge_address_test.go new file mode 100644 index 000000000..1bbc87bfc --- /dev/null +++ b/bridgetracker/bridge_address_test.go @@ -0,0 +1,149 @@ +package bridgetracker + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/agglayer/aggkit/bridgetracker/api" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +var ( + testBridgeAddressNetwork0 = common.HexToAddress("0x2222222222222222222222222222222222222222") + testBridgeAddressNetwork1 = common.HexToAddress("0x3333333333333333333333333333333333333333") +) + +// fakeBridgeAddressResolver is a hand-rolled BridgeAddressResolver for tests: addresses maps +// networkID -> address (BridgeAddress errors if networkID is absent, unless err is set, in +// which case every call fails with it), networkIDs is returned as-is by NetworkIDs. +type fakeBridgeAddressResolver struct { + networkIDs []uint32 + addresses map[uint32]common.Address + err error +} + +func (f *fakeBridgeAddressResolver) NetworkIDs() []uint32 { + return f.networkIDs +} + +func (f *fakeBridgeAddressResolver) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + if f.err != nil { + return common.Address{}, f.err + } + addr, ok := f.addresses[networkID] + if !ok { + return common.Address{}, errors.New("no bridge contract address configured for network") + } + return addr, nil +} + +// TestBridgeAddressHandlerNotRegisteredWithoutResolver verifies both bridge-address endpoints +// are absent (404) when Config.BridgeAddressResolver is left nil +func TestBridgeAddressHandlerNotRegisteredWithoutResolver(t *testing.T) { + _, router := newTestTracker(t) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusNotFound, resp.Code) + + resp = performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusNotFound, resp.Code) +} + +// TestBridgeAddressHandlerAllNetworks verifies GET /bridge-address reports the bridge contract +// address of every network the resolver currently knows about +func TestBridgeAddressHandlerAllNetworks(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{0, 1}, + addresses: map[uint32]common.Address{0: testBridgeAddressNetwork0, 1: testBridgeAddressNetwork1}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.BridgeAddressResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, []api.BridgeAddressItem{ + {NetworkID: 0, BridgeAddress: testBridgeAddressNetwork0}, + {NetworkID: 1, BridgeAddress: testBridgeAddressNetwork1}, + }, body.Bridges) +} + +// TestBridgeAddressHandlerSingleNetwork verifies GET /bridge-address/{network_id} reports only +// the requested network's bridge contract address +func TestBridgeAddressHandlerSingleNetwork(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{0, 1}, + addresses: map[uint32]common.Address{0: testBridgeAddressNetwork0, 1: testBridgeAddressNetwork1}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.BridgeAddressItem + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, api.BridgeAddressItem{NetworkID: 1, BridgeAddress: testBridgeAddressNetwork1}, body) +} + +// TestBridgeAddressHandlerInvalidNetworkID verifies a non-numeric network_id is rejected with 400 +func TestBridgeAddressHandlerInvalidNetworkID(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/foo") + require.Equal(t, http.StatusBadRequest, resp.Code) + + var errData types.ErrorData + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &errData)) + require.Equal(t, http.StatusBadRequest, errData.Code) + require.Contains(t, errData.Message, "network_id") +} + +// TestBridgeAddressHandlerResolverFailure verifies a resolver failure surfaces as 500 +func TestBridgeAddressHandlerResolverFailure(t *testing.T) { + wantErr := errors.New("rollup manager call failed") + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{1}, + err: wantErr, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusInternalServerError, resp.Code) + + resp = performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusInternalServerError, resp.Code) +} diff --git a/bridgetracker/bridgetracker.go b/bridgetracker/bridgetracker.go index 8cde968c8..4c2298986 100644 --- a/bridgetracker/bridgetracker.go +++ b/bridgetracker/bridgetracker.go @@ -42,7 +42,8 @@ func New(cfg *Config) *BridgeTracker { logger: cfg.Logger, supervised: supervised, api: api.NewAPI( - cfg.Logger, cfg.ConfigSHA1, supervised, activity, cfg.RegisterResolveTimeout.Duration, cfg.CORS), + cfg.Logger, cfg.ConfigSHA1, supervised, activity, cfg.BridgeAddressResolver, + cfg.RegisterResolveTimeout.Duration, cfg.CORS), } } diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 127f6d398..7b845d526 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -13,6 +13,7 @@ import ( "github.com/agglayer/aggkit" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/api" + "github.com/agglayer/aggkit/bridgetracker/domain" "github.com/agglayer/aggkit/bridgetracker/types" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" @@ -369,7 +370,7 @@ func TestActivityHandlerHappyPath(t *testing.T) { Logger: log.WithFields("module", "bridgetracker_test"), ConfigSHA1: testConfigSHA1, ActivityScanner: &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{bridge}, + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, }, ActivityClaims: &fakeActivityClaims{ isClaimed: []bool{true}, @@ -387,7 +388,7 @@ func TestActivityHandlerHappyPath(t *testing.T) { require.Equal(t, testFromAddress, body.FromAddress) require.Len(t, body.Bridges, 1) require.Equal(t, "true", body.Bridges[0].Claimed) - require.Equal(t, bridge.OriginNetwork, body.Bridges[0].BridgeNetworkID) + require.Equal(t, testScannedNetworkID, body.Bridges[0].BridgeNetworkID) require.Equal(t, claim.TxHash, body.Bridges[0].Claim.TxHash) require.Equal(t, bridge.DestinationNetwork, body.Bridges[0].ClaimNetworkID) require.NotZero(t, body.Bridges[0].CreationTimestamp) @@ -406,7 +407,7 @@ func TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage(t *testing. Logger: log.WithFields("module", "bridgetracker_test"), ConfigSHA1: testConfigSHA1, ActivityScanner: &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{bridge}, + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, }, ActivityClaims: &fakeActivityClaims{ isClaimed: []bool{false}, @@ -456,7 +457,10 @@ func TestActivityHandlerFilterBridgesPendingExcludesClaimed(t *testing.T) { Logger: log.WithFields("module", "bridgetracker_test"), ConfigSHA1: testConfigSHA1, ActivityScanner: &fakeActivityScanner{ - bridges: []*bridgeservicetypes.BridgeResponse{claimedBridge, pendingBridge}, + bridges: []*domain.ScannedBridge{ + scannedBridge(claimedBridge, testScannedNetworkID), + scannedBridge(pendingBridge, testScannedNetworkID), + }, }, ActivityClaims: &fakeActivityClaims{isClaimed: []bool{true, false}}, }) diff --git a/bridgetracker/config.go b/bridgetracker/config.go index e40edefa1..dc1c999f7 100644 --- a/bridgetracker/config.go +++ b/bridgetracker/config.go @@ -131,6 +131,12 @@ type Config struct { // a separate knob because it governs a different cache. A value <= 0 falls back to // DefaultIdleTimeout. ActivityIdleTimeout types.Duration `mapstructure:"ActivityIdleTimeout"` + + // BridgeAddressResolver wires the optional GET /bridge-address[/{network_id}] endpoint: it + // resolves the bridge contract address for one network, or every network it currently + // knows about. Wired programmatically by the binary (bridgeservicefinder.Finder satisfies + // this port directly); leaving it nil leaves the endpoint unregistered entirely. + BridgeAddressResolver BridgeAddressResolver `mapstructure:"-"` } // Validate checks if the configuration is valid diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go index 010c181b4..1d19d5651 100644 --- a/bridgetracker/domain/activity.go +++ b/bridgetracker/domain/activity.go @@ -9,6 +9,21 @@ import ( "github.com/ethereum/go-ethereum/common" ) +// ScannedBridge pairs a raw bridge event with the network whose bridge service actually +// returned it — i.e. the network the bridge-creating tx was sent to. This is deliberately NOT +// the same thing as Bridge.OriginNetwork, which is the origin network of the bridged ASSET: the +// two coincide for a first-time bridge of a native asset, but differ when re-bridging an asset +// that itself originated on a third network (e.g. an asset native to L1, already bridged to L2 +// A, now bridged again from L2 A to L2 B — that bridge is reported by L2 A's own bridge service, +// yet its OriginNetwork still reads L1). Anything keyed by "which network created this deposit" +// — isClaimed()'s sourceBridgeNetwork, the GlobalIndex encoding, the tracker's TrackingID — must +// use NetworkID here, never Bridge.OriginNetwork (see bridgeservice/utils.go's NewBridgeResponse, +// which threads the requested network — not Bridge.OriginNetwork — into GlobalIndexForBridge). +type ScannedBridge struct { + Bridge *bridgeservicetypes.BridgeResponse + NetworkID uint32 +} + // ActivityEntry is one bridge found for a from_address, as of the last time it was (re)checked // (see ActivityQuerier). Bridge and Claim are stored exactly as the bridge service returned // them — this feature is a cache over that data, not a reinterpretation of it (see @@ -16,6 +31,9 @@ import ( type ActivityEntry struct { // Bridge is the raw bridge event, as returned by the origin network's bridge service Bridge *bridgeservicetypes.BridgeResponse + // BridgeNetworkID is the network whose bridge service reported Bridge (see ScannedBridge) — + // NOT necessarily Bridge.OriginNetwork + BridgeNetworkID uint32 // ClaimStatus is the tri-state result of the destination bridge contract's isClaimed() // call the last time it was checked: Unclaimed, Claimed, or Error if the check itself // failed (e.g. no bridge contract address configured for the destination network) — a @@ -53,7 +71,7 @@ type ActivityBridgeScanner interface { // first known bridge is guaranteed already known too (see sources.ActivitySource) BridgesFrom( ctx context.Context, fromAddress common.Address, known map[string]struct{}, - ) ([]*bridgeservicetypes.BridgeResponse, error) + ) ([]*ScannedBridge, error) } // ActivityClaimChecker is the driven port to a bridge's claim state on its destination @@ -61,10 +79,10 @@ type ActivityBridgeScanner interface { // destination network's bridge service indexed for it once claimed type ActivityClaimChecker interface { // IsClaimed calls the destination bridge contract's isClaimed() for bridge - IsClaimed(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (bool, error) + IsClaimed(ctx context.Context, bridge *ScannedBridge) (bool, error) // ClaimInfo returns the raw claim record for bridge from its destination network's // bridge service, or nil if the indexer has not recorded it yet - ClaimInfo(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (*bridgeservicetypes.ClaimResponse, error) + ClaimInfo(ctx context.Context, bridge *ScannedBridge) (*bridgeservicetypes.ClaimResponse, error) } // ActivityQuerier is the driven port the GET /activity/from/{from_address} HTTP command diff --git a/bridgetracker/domain/bridge_address.go b/bridgetracker/domain/bridge_address.go new file mode 100644 index 000000000..cb55f9565 --- /dev/null +++ b/bridgetracker/domain/bridge_address.go @@ -0,0 +1,20 @@ +package domain + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" +) + +// BridgeAddressResolver is the driven port behind GET /bridge-address[/{network_id}]: it +// resolves the bridge contract address for one network, and enumerates every network currently +// known, so "every network" can be answered without a fixed config list. +// bridgeservicefinder.Finder satisfies it directly (see sources.NetworkLister, which widens the +// same shape further for the activity endpoint). +type BridgeAddressResolver interface { + // NetworkIDs returns the networkIDs of every network currently resolved + NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID (see + // bridgeservicefinder.Finder.BridgeAddress for the resolution/override rules) + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) +} diff --git a/bridgetracker/domain/bridge_step_path.go b/bridgetracker/domain/bridge_step_path.go index 75dd81ded..19b8dddc0 100644 --- a/bridgetracker/domain/bridge_step_path.go +++ b/bridgetracker/domain/bridge_step_path.go @@ -23,7 +23,7 @@ type BridgeStepPath struct { // (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), // *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData // (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - // *types.ClaimResult (StepWaitingClaim). nil until the step produces one, and for steps + // *types.ClaimResult (StepClaimed). nil until the step produces one, and for steps // that never do. Most steps only set this once Done, but StepCertificatePending (Status // still InProgress) may already carry the certificate's current, not yet settled, status — // see ErrCertificateNotSettled diff --git a/bridgetracker/domain/resolve_step_claimed.go b/bridgetracker/domain/resolve_step_claimed.go new file mode 100644 index 000000000..f4b47a64f --- /dev/null +++ b/bridgetracker/domain/resolve_step_claimed.go @@ -0,0 +1,45 @@ +package domain + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/bridgetracker/types" + aggkitcommon "github.com/agglayer/aggkit/common" +) + +// ClaimSource is the driven port to the claim record of a bridge on its destination network +type ClaimSource interface { + // ClaimFor returns the claim transaction of bridge on the destination network, or nil if + // the destination network's bridge service has not indexed it yet + ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) +} + +// ClaimedResolver resolves StepClaimed: once StepWaitingClaim's on-chain check confirms the +// bridge is claimed, this fetches the claim transaction/block from the destination network's +// bridge service. It can stay pending a little after StepWaitingClaim completes — the bridge +// service may not have indexed the claim tx yet even though isClaimed() already returns true — +// so it does get its own fact check, unlike a plain waypoint +type ClaimedResolver struct { + port ClaimSource +} + +// NewClaimedResolver returns a ClaimedResolver reading claims through port +func NewClaimedResolver(port ClaimSource) *ClaimedResolver { + return &ClaimedResolver{port: port} +} + +// Resolve implements StepResolver +func (r *ClaimedResolver) Resolve( + logger aggkitcommon.Logger, ctx context.Context, tracking *TrackingData, _ int, +) (any, error) { + claim, err := r.port.ClaimFor(ctx, tracking.Info()) + if err != nil { + return nil, fmt.Errorf("claim info: %w", err) + } + if claim == nil { + return nil, ErrStepPending + } + + return claim, nil +} diff --git a/bridgetracker/domain/resolve_step_waiting_claim.go b/bridgetracker/domain/resolve_step_waiting_claim.go index 5d968a30b..8a7d7f3c0 100644 --- a/bridgetracker/domain/resolve_step_waiting_claim.go +++ b/bridgetracker/domain/resolve_step_waiting_claim.go @@ -4,27 +4,27 @@ import ( "context" "fmt" - "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" ) -// ClaimSource is the driven port to the claim state of a bridge on its destination network -type ClaimSource interface { - // ClaimFor returns the claim transaction of bridge on the destination network, or nil if - // it has not been claimed yet - ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) +// ClaimChecker is the driven port to whether a bridge has been claimed on its destination +// network: the on-chain isClaimed() call, the same source of truth ActivityClaimChecker uses for +// the activity endpoint (see domain.ActivityClaimChecker) +type ClaimChecker interface { + // IsClaimed reports whether bridge has already been claimed on its destination network + IsClaimed(ctx context.Context, bridge *BridgeInfo) (bool, error) } // WaitingClaimResolver resolves StepWaitingClaim: whether the bridge has been claimed on its -// destination network. Completing it also completes StepClaimed in the same call (see -// UpdateStep): Claimed never has a fact check of its own, it is reached the instant the claim -// is found +// destination network, per the destination bridge contract's own isClaimed() — fast and +// authoritative, but it carries no claim transaction/block details of its own (see +// ClaimedResolver, which resolves those once this step completes) type WaitingClaimResolver struct { - port ClaimSource + port ClaimChecker } -// NewWaitingClaimResolver returns a WaitingClaimResolver reading claims through port -func NewWaitingClaimResolver(port ClaimSource) *WaitingClaimResolver { +// NewWaitingClaimResolver returns a WaitingClaimResolver checking claims through port +func NewWaitingClaimResolver(port ClaimChecker) *WaitingClaimResolver { return &WaitingClaimResolver{port: port} } @@ -32,13 +32,13 @@ func NewWaitingClaimResolver(port ClaimSource) *WaitingClaimResolver { func (r *WaitingClaimResolver) Resolve( logger aggkitcommon.Logger, ctx context.Context, tracking *TrackingData, _ int, ) (any, error) { - claim, err := r.port.ClaimFor(ctx, tracking.Info()) + claimed, err := r.port.IsClaimed(ctx, tracking.Info()) if err != nil { return nil, fmt.Errorf("claim status: %w", err) } - if claim == nil { + if !claimed { return nil, ErrStepPending } - return claim, nil + return nil, nil } diff --git a/bridgetracker/domain/resolve_step_waiting_ger_injection.go b/bridgetracker/domain/resolve_step_waiting_ger_injection.go index e6b20d8e6..603eaa348 100644 --- a/bridgetracker/domain/resolve_step_waiting_ger_injection.go +++ b/bridgetracker/domain/resolve_step_waiting_ger_injection.go @@ -55,7 +55,14 @@ func (r *WaitingGERInjectionResolver) Resolve( return nil, ErrStepPending } - return &types.InjectedGERResult{GER: *injected.GER}, nil + result := &types.InjectedGERResult{GER: *injected.GER} + if injected.BlockNumber != nil { + result.BlockNumber = *injected.BlockNumber + } + if injected.BlockTimestamp != nil { + result.BlockTimestamp = *injected.BlockTimestamp + } + return result, nil } func (r *WaitingGERInjectionResolver) getLeafIndexFromPreviousStep( diff --git a/bridgetracker/domain/resolve_steps.go b/bridgetracker/domain/resolve_steps.go index 772431c22..1125b85bf 100644 --- a/bridgetracker/domain/resolve_steps.go +++ b/bridgetracker/domain/resolve_steps.go @@ -98,12 +98,12 @@ func currentStepIndex(steps []BridgeStepPath) int { // (a step cannot both fail and complete) and idx+1 is left untouched. With stepErr nil, any // previous Error is cleared instead: a successful fact check, even an inconclusive one, clears a // previous transient failure, evidence the retry is working, not just that a milestone was met. -// Completing idx opens idx+1 as the new current step (InProgress), completing it immediately, -// terminal, if it is StepClaimed — a step that never has a fact check of its own. Returns -// tracking unchanged only when there is truly nothing new to record: not complete, no stepErr, -// no Error to clear, and result unchanged from what is already stored. ResolveSteps calls this -// once per loop iteration, so completing one step (e.g. PendingInclusionResolver, see its doc) -// simply has the next resolver asked in turn +// Completing idx opens idx+1 as the new current step (InProgress) — including StepClaimed, which +// gets its own resolver call (ClaimedResolver) like any other step, ResolveSteps simply asks it +// in the same loop iteration. Returns tracking unchanged only when there is truly nothing new to +// record: not complete, no stepErr, no Error to clear, and result unchanged from what is already +// stored. ResolveSteps calls this once per loop iteration, so completing one step (e.g. +// PendingInclusionResolver, see its doc) simply has the next resolver asked in turn func UpdateStep( tracking *TrackingData, idx int, result any, complete bool, stepErr error, now time.Time, ) *TrackingData { @@ -161,11 +161,6 @@ func UpdateStep( next.Error = nil startDate := now next.StartDate = &startDate - if next.Step == types.StepClaimed { - next.Status = types.StepStatusDone - endDate := now - next.EndDate = &endDate - } newSteps[idx+1] = next } diff --git a/bridgetracker/domain/resolve_steps_test.go b/bridgetracker/domain/resolve_steps_test.go index d802056b2..95e20c596 100644 --- a/bridgetracker/domain/resolve_steps_test.go +++ b/bridgetracker/domain/resolve_steps_test.go @@ -22,6 +22,7 @@ type fakeFacts struct { injectedGER *types.GERData injectedGERAtIndex *types.GERData l1InfoTreeIndex *uint32 + claimed bool claim *types.ClaimResult settlement *types.L1SettledGERResult @@ -31,6 +32,7 @@ type fakeFacts struct { injectedGERErr error injectedGERAtIndexErr error l1InfoTreeIndexErr error + claimedErr error claimErr error settlementErr error @@ -88,6 +90,11 @@ func (f *fakeFacts) L1InfoTreeIndexForGER( return f.l1InfoTreeIndex, f.l1InfoTreeIndexErr } +func (f *fakeFacts) IsClaimed(_ context.Context, _ *BridgeInfo) (bool, error) { + f.queried = append(f.queried, "isClaimed") + return f.claimed, f.claimedErr +} + func (f *fakeFacts) ClaimFor(_ context.Context, _ *BridgeInfo) (*types.ClaimResult, error) { f.queried = append(f.queried, "claimFor") return f.claim, f.claimErr @@ -115,6 +122,7 @@ func testResolvers(f *fakeFacts) map[types.BridgeStep]StepResolver { types.StepWaitL1SettledGER: NewWaitL1SettledGERResolver(f, f), types.StepWaitingGERInjection: NewWaitingGERInjectionResolver(f), types.StepWaitingClaim: NewWaitingClaimResolver(f), + types.StepClaimed: NewClaimedResolver(f), } } @@ -258,7 +266,7 @@ func TestResolveSteps(t *testing.T) { }, expectedStep: types.StepWaitingClaim, expectedQueried: []string{ - "originLER", "certificate", "certificate", "settlementGERUpdate", "claimFor", + "originLER", "certificate", "certificate", "settlementGERUpdate", "isClaimed", }, resultOf: types.StepWaitL1SettledGER, result: settlementResult, @@ -282,14 +290,15 @@ func TestResolveSteps(t *testing.T) { certificate: settledCert, settlement: settlementResult, injectedGERAtIndex: injectedGER, + claimed: true, claim: claim, }, expectedStep: types.StepClaimed, expectedQueried: []string{ "originLER", "certificate", "certificate", "settlementGERUpdate", - "injectedGERAtIndex", "claimFor", + "injectedGERAtIndex", "isClaimed", "claimFor", }, - resultOf: types.StepWaitingClaim, + resultOf: types.StepClaimed, result: claim, }, { @@ -341,11 +350,13 @@ func TestResolveSteps(t *testing.T) { }, }, { - name: "L1->L2 claimed", - bridgeType: types.BridgeTypeL1ToL2, - facts: fakeFacts{originGER: originGER, injectedGERAtIndex: injectedGER, claim: claim}, + name: "L1->L2 claimed", + bridgeType: types.BridgeTypeL1ToL2, + facts: fakeFacts{ + originGER: originGER, injectedGERAtIndex: injectedGER, claimed: true, claim: claim, + }, expectedStep: types.StepClaimed, - expectedQueried: []string{"originGER", "injectedGERAtIndex", "claimFor"}, + expectedQueried: []string{"originGER", "injectedGERAtIndex", "isClaimed", "claimFor"}, }, { name: "L1->L2 with GER update already done skips OriginGER", @@ -366,9 +377,9 @@ func TestResolveSteps(t *testing.T) { expectedQueried: []string{"injectedGERAtIndex"}, }, { - name: "L2->L2 with every milestone done but the claim only queries the claim", + name: "L2->L2 with every milestone done but the claim only queries isClaimed and claimFor", bridgeType: types.BridgeTypeL2ToL2, - facts: fakeFacts{claim: claim}, + facts: fakeFacts{claimed: true, claim: claim}, prevSteps: []BridgeStepPath{ {Step: types.StepWaitingLERUpdate, Status: types.StepStatusDone}, {Step: types.StepPendingInclusion, Status: types.StepStatusDone}, @@ -378,8 +389,8 @@ func TestResolveSteps(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, expectedStep: types.StepClaimed, - expectedQueried: []string{"claimFor"}, - resultOf: types.StepWaitingClaim, + expectedQueried: []string{"isClaimed", "claimFor"}, + resultOf: types.StepClaimed, result: claim, }, { @@ -504,11 +515,24 @@ func TestResolveStepsErrors(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: settlementResult, - claimErr: factsErr, + claimedErr: factsErr, }, expectedErr: "claim status", expectedStep: types.StepWaitingClaim, }, + { + name: "claim info error", + bridgeType: types.BridgeTypeL2ToL1, + facts: fakeFacts{ + originLER: originLER, + certificate: settledCert, + settlement: settlementResult, + claimed: true, + claimErr: factsErr, + }, + expectedErr: "claim info", + expectedStep: types.StepClaimed, + }, } for _, tc := range testCases { @@ -582,7 +606,7 @@ func TestUpdateStep(t *testing.T) { }, steps) }) - t.Run("terminal step completes the moment it is reached", func(t *testing.T) { + t.Run("completing WaitingClaim opens Claimed as InProgress, not auto-completed", func(t *testing.T) { t.Parallel() tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ @@ -592,14 +616,13 @@ func TestUpdateStep(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, t1) - claim := &types.ClaimResult{ClaimTx: common.Hash{2}, BlockNumber: 200} - advanced := UpdateStep(tracking, 2, claim, true, nil, t2) + advanced := UpdateStep(tracking, 2, nil, true, nil, t2) last := advanced.AllSteps()[len(advanced.AllSteps())-1] require.Equal(t, types.StepClaimed, last.Step) - require.Equal(t, types.StepStatusDone, last.Status) + require.Equal(t, types.StepStatusInProgress, last.Status, "Claimed now needs its own resolver call to complete") require.Equal(t, &t2, last.StartDate) - require.Equal(t, &t2, last.EndDate) + require.Nil(t, last.EndDate) }) t.Run("a successful check clears a previous transient error even without progress", func(t *testing.T) { @@ -777,7 +800,7 @@ func TestCertificateResolverSkipsWaypoints(t *testing.T) { result, err := ResolveSteps(context.Background(), log.NewLoggerNil(), testResolvers(facts), tracking, t2) require.NoError(t, err) - require.Equal(t, []string{"certificate", "certificate", "claimFor"}, facts.queried) + require.Equal(t, []string{"certificate", "certificate", "isClaimed"}, facts.queried) steps := result.AllSteps() require.Equal(t, types.StepStatusDone, steps[1].Status, "PendingInclusion skipped straight through") diff --git a/bridgetracker/engine.go b/bridgetracker/engine.go index 96c55bb78..63653577b 100644 --- a/bridgetracker/engine.go +++ b/bridgetracker/engine.go @@ -82,6 +82,7 @@ type EngineSources struct { GERs GERSource WaitingGERUpdateSource domain.WaitingGERUpdateSource LERs LERSource + ClaimChecker ClaimChecker Claims ClaimSource Settlement SettlementSource } @@ -119,6 +120,8 @@ func NewEngine( return nil, errors.New("engine requires a GERSource") case sources.LERs == nil: return nil, errors.New("engine requires a LERSource") + case sources.ClaimChecker == nil: + return nil, errors.New("engine requires a ClaimChecker") case sources.Claims == nil: return nil, errors.New("engine requires a ClaimSource") case sources.Settlement == nil: @@ -144,7 +147,8 @@ func createResolvers(logger aggkitcommon.Logger, sources EngineSources) map[type types.StepCertificatePending: domain.NewCertificatePendingResolver(sources.Certificates), types.StepWaitL1SettledGER: domain.NewWaitL1SettledGERResolver(sources.Settlement, sources.GERs), types.StepWaitingGERInjection: domain.NewWaitingGERInjectionResolver(sources.GERs), - types.StepWaitingClaim: domain.NewWaitingClaimResolver(sources.Claims), + types.StepWaitingClaim: domain.NewWaitingClaimResolver(sources.ClaimChecker), + types.StepClaimed: domain.NewClaimedResolver(sources.Claims), } } diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 817bfa108..21f5981ba 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -41,8 +41,10 @@ type fakeSources struct { cert *types.CertificateInclusionData certErr error - claim *types.ClaimResult - claimErr error + claimed bool + claimedErr error + claim *types.ClaimResult + claimErr error settlement *types.L1SettledGERResult settlementErr error @@ -96,6 +98,10 @@ func (f *fakeSources) L1InfoTreeIndexForGER( return f.l1InfoTreeIndex, f.l1InfoTreeIndexErr } +func (f *fakeSources) IsClaimed(_ context.Context, _ *BridgeInfo) (bool, error) { + return f.claimed, f.claimedErr +} + func (f *fakeSources) ClaimFor(_ context.Context, _ *BridgeInfo) (*types.ClaimResult, error) { return f.claim, f.claimErr } @@ -108,7 +114,7 @@ func (f *fakeSources) SettlementGERUpdate( func (f *fakeSources) engineSources() EngineSources { return EngineSources{ - Bridges: f, Certificates: f, GERs: f, LERs: f, Claims: f, Settlement: f, + Bridges: f, Certificates: f, GERs: f, LERs: f, ClaimChecker: f, Claims: f, Settlement: f, WaitingGERUpdateSource: f, } } @@ -186,6 +192,11 @@ func TestEngineNewValidation(t *testing.T) { _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) require.ErrorContains(t, err, "LERSource") + sources = f.engineSources() + sources.ClaimChecker = nil + _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) + require.ErrorContains(t, err, "ClaimChecker") + sources = f.engineSources() sources.Claims = nil _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) @@ -557,17 +568,25 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { } injectedGER := common.HexToHash("0x04") - f.injectedGERAtIndex = &types.GERData{NetworkID: 2, GER: &injectedGER, LERType: types.LERTypeLocal} + injectedGERBlockNumber := uint64(200) + injectedGERTimestamp := uint64(1700000000) + f.injectedGERAtIndex = &types.GERData{ + NetworkID: 2, GER: &injectedGER, LERType: types.LERTypeLocal, + BlockNumber: &injectedGERBlockNumber, BlockTimestamp: &injectedGERTimestamp, + } engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) allSteps = tracking.AllSteps() require.Equal(t, types.StepWaitingClaim, currentStep(t, store)) for _, sp := range allSteps { if sp.Step == types.StepWaitingGERInjection { - require.Equal(t, &types.InjectedGERResult{GER: injectedGER}, sp.Result()) + require.Equal(t, &types.InjectedGERResult{ + GER: injectedGER, BlockNumber: injectedGERBlockNumber, BlockTimestamp: injectedGERTimestamp, + }, sp.Result()) } } + f.claimed = true f.claim = &types.ClaimResult{ClaimTx: common.HexToHash("0x03"), BlockNumber: 30} engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) @@ -576,10 +595,10 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { require.Equal(t, types.StepClaimed, allSteps[*tracking.StepIndex()].Step) final := allSteps[len(allSteps)-1] require.Equal(t, types.StepClaimed, final.Step) - require.Equal(t, types.StepStatusDone, final.Status, "Claimed is terminal: done, not inProgress") + require.Equal(t, types.StepStatusDone, final.Status, "Claimed is done once the bridge service confirms the claim") for _, sp := range allSteps { - if sp.Step == types.StepWaitingClaim { + if sp.Step == types.StepClaimed { require.Equal(t, f.claim, sp.Result()) } } @@ -634,7 +653,8 @@ func TestEngineIncrementalResolution(t *testing.T) { require.Nil(t, tracking.Error(), "done milestones must not be re-queried") require.Equal(t, types.StepWaitingClaim, currentStep(t, store)) - // the bridge still finishes through the only remaining fact, the claim + // the bridge still finishes through the only remaining facts, the claim status and its record + f.claimed = true f.claim = &types.ClaimResult{ClaimTx: common.HexToHash("0x03"), BlockNumber: 30} engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) diff --git a/bridgetracker/mocks/mock_claim_checker.go b/bridgetracker/mocks/mock_claim_checker.go new file mode 100644 index 000000000..889f0e2dc --- /dev/null +++ b/bridgetracker/mocks/mock_claim_checker.go @@ -0,0 +1,95 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + bridgetracker "github.com/agglayer/aggkit/bridgetracker" + + mock "github.com/stretchr/testify/mock" +) + +// ClaimChecker is an autogenerated mock type for the ClaimChecker type +type ClaimChecker struct { + mock.Mock +} + +type ClaimChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *ClaimChecker) EXPECT() *ClaimChecker_Expecter { + return &ClaimChecker_Expecter{mock: &_m.Mock} +} + +// IsClaimed provides a mock function with given fields: ctx, bridge +func (_m *ClaimChecker) IsClaimed(ctx context.Context, bridge *bridgetracker.BridgeInfo) (bool, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for IsClaimed") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *bridgetracker.BridgeInfo) (bool, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *bridgetracker.BridgeInfo) bool); ok { + r0 = rf(ctx, bridge) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, *bridgetracker.BridgeInfo) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ClaimChecker_IsClaimed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsClaimed' +type ClaimChecker_IsClaimed_Call struct { + *mock.Call +} + +// IsClaimed is a helper method to define mock.On call +// - ctx context.Context +// - bridge *bridgetracker.BridgeInfo +func (_e *ClaimChecker_Expecter) IsClaimed(ctx interface{}, bridge interface{}) *ClaimChecker_IsClaimed_Call { + return &ClaimChecker_IsClaimed_Call{Call: _e.mock.On("IsClaimed", ctx, bridge)} +} + +func (_c *ClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *bridgetracker.BridgeInfo)) *ClaimChecker_IsClaimed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*bridgetracker.BridgeInfo)) + }) + return _c +} + +func (_c *ClaimChecker_IsClaimed_Call) Return(_a0 bool, _a1 error) *ClaimChecker_IsClaimed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *bridgetracker.BridgeInfo) (bool, error)) *ClaimChecker_IsClaimed_Call { + _c.Call.Return(run) + return _c +} + +// NewClaimChecker creates a new instance of ClaimChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClaimChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *ClaimChecker { + mock := &ClaimChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/ports.go b/bridgetracker/ports.go index ee7b5bc94..2d9bbfac7 100644 --- a/bridgetracker/ports.go +++ b/bridgetracker/ports.go @@ -102,10 +102,18 @@ type LERSource interface { OriginLER(ctx context.Context, bridge *BridgeInfo) (*types.LERUpdateResult, error) } -// ClaimSource is the driven port to the claim state of a bridge on its destination network +// ClaimChecker is the driven port to whether a bridge has been claimed on its destination +// network, per isClaimed() on the destination bridge contract — the on-chain source of truth +// StepWaitingClaim resolves against +type ClaimChecker interface { + // IsClaimed reports whether bridge has already been claimed on its destination network + IsClaimed(ctx context.Context, bridge *BridgeInfo) (bool, error) +} + +// ClaimSource is the driven port to the claim record of a bridge on its destination network type ClaimSource interface { // ClaimFor returns the claim transaction of the bridge on the destination network, or - // nil if it has not been claimed yet + // nil if the destination network's bridge service has not indexed it yet ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) } @@ -123,6 +131,10 @@ type ActivityClaimChecker = domain.ActivityClaimChecker // ActivityQuerier is the driven port the activity endpoint depends on type ActivityQuerier = domain.ActivityQuerier +// BridgeAddressResolver is the driven port the bridge-address endpoint depends on: it resolves +// the bridge contract address for one network, or enumerates every network currently known +type BridgeAddressResolver = domain.BridgeAddressResolver + // SettlementSource is the driven port to the L1 evidence a certificate's settlement produces: // the RollupManager/GlobalExitRoot events (VerifyBatchesTrustedAggregator, UpdateL1InfoTree[V2]) // emitted by the settlement tx itself diff --git a/bridgetracker/sources/activity.go b/bridgetracker/sources/activity.go index 40f86084c..23b2b3849 100644 --- a/bridgetracker/sources/activity.go +++ b/bridgetracker/sources/activity.go @@ -3,13 +3,10 @@ package sources import ( "context" "fmt" - "sync" - "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" "github.com/agglayer/aggkit/bridgeservice/client" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" - aggkittypes "github.com/agglayer/aggkit/types" - "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/agglayer/aggkit/bridgetracker/domain" "github.com/ethereum/go-ethereum/common" ) @@ -31,27 +28,18 @@ type NetworkLister interface { BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) } -// claimChecker is the minimal bridge contract surface ActivitySource needs to check a bridge's -// on-chain claim state; *agglayerbridgel2.Agglayerbridgel2 satisfies it -type claimChecker interface { - IsClaimed(opts *bind.CallOpts, leafIndex uint32, sourceBridgeNetwork uint32) (bool, error) -} - // ActivitySource implements bridgetracker.ActivityBridgeScanner and ActivityClaimChecker: it // scans every network the finder currently knows about for bridges sent by a given address // (via each network's own bridge service), and resolves a bridge's claim state on its // destination network — isClaimed() on the destination bridge contract as the source of truth, // then the destination bridge service's own claim record once claimed. type ActivitySource struct { - services *bridgeServiceClients - finder NetworkLister - ethClients EthClientResolver - // newContract builds the claim-checking contract binding for a destination network, - // injectable for tests. Defaults to agglayerbridgel2.NewAgglayerbridgel2 - newContract func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) - - mu sync.Mutex - contracts map[uint32]claimChecker // destination networkID -> bound contract, built lazily + services *bridgeServiceClients + finder NetworkLister + // contractClaimCheckers resolves/caches the on-chain isClaimed() binding per destination + // network; embedded so tests can still reach newContract directly (see claim_checker.go, + // shared with ClaimChecker so the binding/cache logic isn't duplicated between them) + *contractClaimCheckers } // NewActivitySource returns an ActivitySource resolving bridge services, JSON-RPC clients and @@ -60,13 +48,9 @@ type ActivitySource struct { // resolved and overridden) func NewActivitySource(finder NetworkLister, ethClients EthClientResolver) *ActivitySource { return &ActivitySource{ - services: newBridgeServiceClients(finder), - finder: finder, - ethClients: ethClients, - newContract: func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) { - return agglayerbridgel2.NewAgglayerbridgel2(addr, c) - }, - contracts: make(map[uint32]claimChecker), + services: newBridgeServiceClients(finder), + finder: finder, + contractClaimCheckers: newContractClaimCheckers(finder, ethClients), } } @@ -78,10 +62,10 @@ func NewActivitySource(finder NetworkLister, ethClients EthClientResolver) *Acti // every other network's activity. func (s *ActivitySource) BridgesFrom( ctx context.Context, fromAddress common.Address, known map[string]struct{}, -) ([]*bridgeservicetypes.BridgeResponse, error) { +) ([]*domain.ScannedBridge, error) { addr := fromAddress.Hex() - var all []*bridgeservicetypes.BridgeResponse + var all []*domain.ScannedBridge for _, networkID := range s.finder.NetworkIDs() { svc, err := s.services.aggkitBridgeClientFor(networkID) if err != nil { @@ -102,12 +86,14 @@ func (s *ActivitySource) BridgesFrom( // soon as either a page shorter than pageSize is returned (no more data) or a bridge already in // known is reached. The latter is safe because the feed is append-only and strictly ordered: // once a known bridge is seen, every bridge after it (same page or later pages) is guaranteed -// already known too, so nothing new is missed by stopping there. +// already known too, so nothing new is missed by stopping there. Each returned bridge is paired +// with networkID — the network whose bridge service reported it — via domain.ScannedBridge, +// since that is NOT always the same as the bridge's own OriginNetwork field (see ScannedBridge). func fetchNewBridgesFrom( ctx context.Context, svc *client.Client, networkID uint32, fromAddress string, pageSize uint32, known map[string]struct{}, -) ([]*bridgeservicetypes.BridgeResponse, error) { - var out []*bridgeservicetypes.BridgeResponse +) ([]*domain.ScannedBridge, error) { + var out []*domain.ScannedBridge for page := uint32(1); ; page++ { res, err := svc.GetBridges(ctx, client.GetBridgesParams{ NetworkID: networkID, @@ -122,7 +108,7 @@ func fetchNewBridgesFrom( if _, ok := known[b.GlobalIndex.String()]; ok { return out, nil } - out = append(out, b) + out = append(out, &domain.ScannedBridge{Bridge: b, NetworkID: networkID}) } if uint32(len(res.Bridges)) < pageSize { return out, nil @@ -131,65 +117,36 @@ func fetchNewBridgesFrom( } // IsClaimed implements bridgetracker.ActivityClaimChecker: it calls isClaimed() on bridge's -// destination bridge contract -func (s *ActivitySource) IsClaimed(ctx context.Context, bridge *bridgeservicetypes.BridgeResponse) (bool, error) { - contract, err := s.claimCheckerFor(ctx, bridge.DestinationNetwork) - if err != nil { - return false, err - } - return contract.IsClaimed(&bind.CallOpts{Context: ctx}, bridge.DepositCount, bridge.OriginNetwork) +// destination bridge contract. The on-chain sourceBridgeNetwork argument is bridge.NetworkID — +// the network the bridge-creating tx was actually sent to — never bridge.Bridge.OriginNetwork, +// which can differ for a re-bridged asset (see domain.ScannedBridge) +func (s *ActivitySource) IsClaimed(ctx context.Context, bridge *domain.ScannedBridge) (bool, error) { + return s.isClaimed(ctx, bridge.Bridge.DestinationNetwork, bridge.Bridge.DepositCount, bridge.NetworkID) } // ClaimInfo implements bridgetracker.ActivityClaimChecker: it asks bridge's destination // network's bridge service for the claim record matching bridge's global index func (s *ActivitySource) ClaimInfo( - ctx context.Context, bridge *bridgeservicetypes.BridgeResponse, + ctx context.Context, bridge *domain.ScannedBridge, ) (*bridgeservicetypes.ClaimResponse, error) { - svc, err := s.services.aggkitBridgeClientFor(bridge.DestinationNetwork) + svc, err := s.services.aggkitBridgeClientFor(bridge.Bridge.DestinationNetwork) if err != nil { return nil, err } res, err := svc.GetClaims(ctx, client.GetClaimsParams{ - NetworkID: bridge.DestinationNetwork, - GlobalIndex: bridge.GlobalIndex, + NetworkID: bridge.Bridge.DestinationNetwork, + GlobalIndex: bridge.Bridge.GlobalIndex, }) if isNotFound(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("fetching claim of global index %s on network %d: %w", - bridge.GlobalIndex, bridge.DestinationNetwork, err) + bridge.Bridge.GlobalIndex, bridge.Bridge.DestinationNetwork, err) } if res.Count == 0 || len(res.Claims) == 0 { return nil, nil } return res.Claims[0], nil } - -// claimCheckerFor returns (building and caching if necessary) the claim-checking contract -// binding for the given destination network -func (s *ActivitySource) claimCheckerFor(ctx context.Context, networkID uint32) (claimChecker, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if c, ok := s.contracts[networkID]; ok { - return c, nil - } - - addr, err := s.finder.BridgeAddress(ctx, networkID) - if err != nil { - return nil, fmt.Errorf("resolving bridge contract address for network %d: %w", networkID, err) - } - rpcClient, err := s.ethClients.RPCClientFor(ctx, networkID) - if err != nil { - return nil, fmt.Errorf("resolving JSON-RPC client for network %d: %w", networkID, err) - } - contract, err := s.newContract(addr, rpcClient) - if err != nil { - return nil, fmt.Errorf("binding bridge contract %s on network %d: %w", addr, networkID, err) - } - - s.contracts[networkID] = contract - return contract, nil -} diff --git a/bridgetracker/sources/activity_test.go b/bridgetracker/sources/activity_test.go index eea298117..ef29a14ec 100644 --- a/bridgetracker/sources/activity_test.go +++ b/bridgetracker/sources/activity_test.go @@ -12,6 +12,7 @@ import ( bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgeservicefinder" + "github.com/agglayer/aggkit/bridgetracker/domain" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -147,7 +148,7 @@ func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) globalIndexes := make([]int64, 0, len(items)) for _, item := range items { - globalIndexes = append(globalIndexes, item.GlobalIndex.Int64()) + globalIndexes = append(globalIndexes, item.Bridge.GlobalIndex.Int64()) } require.ElementsMatch(t, []int64{1, 2, 3, 5}, globalIndexes) } @@ -200,7 +201,7 @@ func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { items, err := fetchNewBridgesFrom(t.Context(), client, 1, testFromAddress, 1, known) require.NoError(t, err) require.Len(t, items, 1) - require.Equal(t, int64(3), items[0].GlobalIndex.Int64()) + require.Equal(t, int64(3), items[0].Bridge.GlobalIndex.Int64()) } // TestActivitySource_IsClaimed_NoBridgeAddrConfigured verifies IsClaimed errors clearly when @@ -208,15 +209,17 @@ func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { func TestActivitySource_IsClaimed_NoBridgeAddrConfigured(t *testing.T) { source := NewActivitySource(fakeNetworkLister{}, StaticClients{}) - bridge := bridgeResponse(1, 2, 3, testFromAddress, 1) + bridge := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 3, testFromAddress, 1), NetworkID: 1} _, err := source.IsClaimed(t.Context(), bridge) require.ErrorContains(t, err, "no bridge contract address configured for network 2") } -// TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork verifies IsClaimed -// binds the destination network's contract and calls isClaimed(depositCount, originNetwork), and -// that the binding is cached across calls. -func TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork(t *testing.T) { +// TestActivitySource_IsClaimed_UsesScannedNetworkNotBridgeOriginNetwork verifies IsClaimed binds +// the destination network's contract and calls isClaimed(depositCount, scannedNetworkID) using +// ScannedBridge.NetworkID — never Bridge.OriginNetwork, which is set here to a deliberately +// different value to prove the two are not confused (see domain.ScannedBridge) — and that the +// binding is cached across calls. +func TestActivitySource_IsClaimed_UsesScannedNetworkNotBridgeOriginNetwork(t *testing.T) { destAddr := common.HexToAddress("0xdead") client := StaticClients{2: nil} @@ -230,12 +233,15 @@ func TestActivitySource_IsClaimed_CallsContractWithDepositCountAndOriginNetwork( return stub, nil } - bridge := bridgeResponse(5, 2, 9, testFromAddress, 1) + // OriginNetwork (99) is the asset-origin decoy: isClaimed must use NetworkID (5, the network + // the deposit tx was actually sent to) instead + const scannedNetworkID = uint32(5) + bridge := &domain.ScannedBridge{Bridge: bridgeResponse(99, 2, 9, testFromAddress, 1), NetworkID: scannedNetworkID} claimed, err := source.IsClaimed(t.Context(), bridge) require.NoError(t, err) require.True(t, claimed) require.Equal(t, uint32(9), stub.lastLeafIndex) - require.Equal(t, uint32(5), stub.lastSourceNetwork) + require.Equal(t, scannedNetworkID, stub.lastSourceNetwork) // A second call for the same destination network reuses the cached binding _, err = source.IsClaimed(t.Context(), bridge) @@ -268,12 +274,12 @@ func TestActivitySource_ClaimInfo(t *testing.T) { lister := fakeNetworkLister{networkIDs: []uint32{2}, url: url} source := NewActivitySource(lister, nil) - found := bridgeResponse(1, 2, 0, testFromAddress, 1) + found := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 0, testFromAddress, 1), NetworkID: 1} got, err := source.ClaimInfo(t.Context(), found) require.NoError(t, err) require.Equal(t, claim, got) - notIndexedYet := bridgeResponse(1, 2, 0, testFromAddress, 999) + notIndexedYet := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 0, testFromAddress, 999), NetworkID: 1} got, err = source.ClaimInfo(t.Context(), notIndexedYet) require.NoError(t, err) require.Nil(t, got) diff --git a/bridgetracker/sources/claim.go b/bridgetracker/sources/claim.go index 9e1c8f690..0c88d4b4d 100644 --- a/bridgetracker/sources/claim.go +++ b/bridgetracker/sources/claim.go @@ -50,7 +50,8 @@ func (s *ClaimSource) ClaimFor( return nil, nil } return &trackertypes.ClaimResult{ - ClaimTx: common.HexToHash(string(claims.Claims[0].TxHash)), - BlockNumber: claims.Claims[0].BlockNum, + ClaimTx: common.HexToHash(string(claims.Claims[0].TxHash)), + BlockNumber: claims.Claims[0].BlockNum, + BlockTimestamp: claims.Claims[0].BlockTimestamp, }, nil } diff --git a/bridgetracker/sources/claim_checker.go b/bridgetracker/sources/claim_checker.go new file mode 100644 index 000000000..02f1523a5 --- /dev/null +++ b/bridgetracker/sources/claim_checker.go @@ -0,0 +1,108 @@ +package sources + +import ( + "context" + "fmt" + "sync" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/agglayer/aggkit/bridgetracker" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// claimChecker is the minimal bridge contract surface needed to check a bridge's on-chain claim +// state; *agglayerbridgel2.Agglayerbridgel2 satisfies it. Shared by ActivitySource (the activity +// endpoint) and ClaimChecker (the tracker engine's StepWaitingClaim) via contractClaimCheckers — +// both resolve the exact same isClaimed() call, just off differently-shaped bridge inputs +type claimChecker interface { + IsClaimed(opts *bind.CallOpts, leafIndex uint32, sourceBridgeNetwork uint32) (bool, error) +} + +// contractClaimCheckers resolves and caches, per destination network, the claim-checking +// contract binding used to call isClaimed() on-chain. Factored out of ActivitySource so +// ClaimChecker (below) reuses the same binding/cache logic instead of duplicating it +type contractClaimCheckers struct { + finder NetworkLister + ethClients EthClientResolver + // newContract builds the claim-checking contract binding for a destination network, + // injectable for tests. Defaults to agglayerbridgel2.NewAgglayerbridgel2 + newContract func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) + + mu sync.Mutex + contracts map[uint32]claimChecker // destination networkID -> bound contract, built lazily +} + +// newContractClaimCheckers returns a contractClaimCheckers resolving bridge contract +// addresses/clients through finder/ethClients +func newContractClaimCheckers(finder NetworkLister, ethClients EthClientResolver) *contractClaimCheckers { + return &contractClaimCheckers{ + finder: finder, + ethClients: ethClients, + newContract: func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) { + return agglayerbridgel2.NewAgglayerbridgel2(addr, c) + }, + contracts: make(map[uint32]claimChecker), + } +} + +// isClaimed calls isClaimed() on destinationNetwork's bridge contract, for the leaf identified +// by depositCount within sourceNetworkID's exit tree +func (c *contractClaimCheckers) isClaimed( + ctx context.Context, destinationNetwork, depositCount, sourceNetworkID uint32, +) (bool, error) { + contract, err := c.claimCheckerFor(ctx, destinationNetwork) + if err != nil { + return false, err + } + return contract.IsClaimed(&bind.CallOpts{Context: ctx}, depositCount, sourceNetworkID) +} + +// claimCheckerFor returns (building and caching if necessary) the claim-checking contract +// binding for the given destination network +func (c *contractClaimCheckers) claimCheckerFor(ctx context.Context, networkID uint32) (claimChecker, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if cc, ok := c.contracts[networkID]; ok { + return cc, nil + } + + addr, err := c.finder.BridgeAddress(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving bridge contract address for network %d: %w", networkID, err) + } + rpcClient, err := c.ethClients.RPCClientFor(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving JSON-RPC client for network %d: %w", networkID, err) + } + contract, err := c.newContract(addr, rpcClient) + if err != nil { + return nil, fmt.Errorf("binding bridge contract %s on network %d: %w", addr, networkID, err) + } + + c.contracts[networkID] = contract + return contract, nil +} + +// ClaimChecker implements bridgetracker.ClaimChecker over StepWaitingClaim: whether a bridge has +// been claimed on its destination network, per isClaimed() on the destination bridge contract — +// the same on-chain check ActivitySource uses for the activity endpoint, applied here to a +// resolved bridgetracker.BridgeInfo instead of a domain.ScannedBridge +type ClaimChecker struct { + *contractClaimCheckers +} + +// NewClaimChecker returns a ClaimChecker resolving bridge contract addresses/clients through +// finder/ethClients +func NewClaimChecker(finder NetworkLister, ethClients EthClientResolver) *ClaimChecker { + return &ClaimChecker{contractClaimCheckers: newContractClaimCheckers(finder, ethClients)} +} + +// IsClaimed implements bridgetracker.ClaimChecker: it calls isClaimed() on bridge's destination +// bridge contract. The on-chain sourceBridgeNetwork argument is bridge.NetworkID — the network +// the bridge-creating tx was actually sent to +func (c *ClaimChecker) IsClaimed(ctx context.Context, bridge *bridgetracker.BridgeInfo) (bool, error) { + return c.isClaimed(ctx, bridge.DestinationNetwork, bridge.DepositCount, bridge.NetworkID) +} diff --git a/bridgetracker/sources/ger.go b/bridgetracker/sources/ger.go index ab4b17579..3e7fb951b 100644 --- a/bridgetracker/sources/ger.go +++ b/bridgetracker/sources/ger.go @@ -274,12 +274,16 @@ func (s *GERSource) InjectedGERAtIndex( ger := common.HexToHash(string(leaf.GlobalExitRoot)) mer := common.HexToHash(string(leaf.MainnetExitRoot)) rer := common.HexToHash(string(leaf.RollupExitRoot)) + blockNumber := leaf.BlockNumber + timestamp := leaf.Timestamp return &trackertypes.GERData{ - NetworkID: bridge.DestinationNetwork, - GER: &ger, - MER: &mer, - RER: &rer, - LERType: trackertypes.LERTypeNA, + NetworkID: bridge.DestinationNetwork, + GER: &ger, + MER: &mer, + RER: &rer, + LERType: trackertypes.LERTypeNA, + BlockNumber: &blockNumber, + BlockTimestamp: ×tamp, }, nil } diff --git a/bridgetracker/sources/sources_test.go b/bridgetracker/sources/sources_test.go index 985a1d144..37740d2b1 100644 --- a/bridgetracker/sources/sources_test.go +++ b/bridgetracker/sources/sources_test.go @@ -209,9 +209,11 @@ type fakeBridgeService struct { injectedLeaf map[string]any // claimsCount is served by /bridge/v1/claims claimsCount int - // claimTxHash and claimBlockNum populate the single claim served when claimsCount > 0 - claimTxHash string - claimBlockNum uint64 + // claimTxHash, claimBlockNum and claimBlockTimestamp populate the single claim served when + // claimsCount > 0 + claimTxHash string + claimBlockNum uint64 + claimBlockTimestamp uint64 // lastLeafIndexQuery records the leaf_index of the last injected-l1-info-leaf request lastLeafIndexQuery string @@ -249,8 +251,8 @@ func (f *fakeBridgeService) start(t *testing.T) NetworkURLResolver { fmt.Fprint(w, `{"claims":[],"count":0}`) return } - fmt.Fprintf(w, `{"claims":[{"tx_hash":"%s","block_num":%d}],"count":%d}`, - f.claimTxHash, f.claimBlockNum, f.claimsCount) + fmt.Fprintf(w, `{"claims":[{"tx_hash":"%s","block_num":%d,"block_timestamp":%d}],"count":%d}`, + f.claimTxHash, f.claimBlockNum, f.claimBlockTimestamp, f.claimsCount) }) server := httptest.NewServer(mux) @@ -361,12 +363,14 @@ func TestGERSourceInjectedGER(t *testing.T) { require.Nil(t, injected) require.Equal(t, "42", fake.lastLeafIndexQuery, "must ask for the covering leaf index") - // injected -> GERData with the leaf roots + // injected -> GERData with the leaf roots and the injection's block number/timestamp fake.injectedLeaf = map[string]any{ "l1_info_tree_index": 42, "global_exit_root": "0x0a", "mainnet_exit_root": "0x0b", "rollup_exit_root": "0x0c", + "block_num": 200, + "timestamp": 1700000000, } injected, err = source.InjectedGER(t.Context(), l1ToL2Bridge()) require.NoError(t, err) @@ -375,6 +379,8 @@ func TestGERSourceInjectedGER(t *testing.T) { require.Equal(t, common.HexToHash("0x0a"), *injected.GER) require.Equal(t, common.HexToHash("0x0b"), *injected.MER) require.Equal(t, common.HexToHash("0x0c"), *injected.RER) + require.Equal(t, uint64(200), *injected.BlockNumber) + require.Equal(t, uint64(1700000000), *injected.BlockTimestamp) } func TestClaimSourceClaimFor(t *testing.T) { @@ -393,11 +399,13 @@ func TestClaimSourceClaimFor(t *testing.T) { fake.claimsCount = 1 fake.claimTxHash = "0x0d" fake.claimBlockNum = 50 + fake.claimBlockTimestamp = 1700000000 claim, err = source.ClaimFor(t.Context(), l1ToL2Bridge()) require.NoError(t, err) require.NotNil(t, claim) require.Equal(t, common.HexToHash("0x0d"), claim.ClaimTx) require.Equal(t, uint64(50), claim.BlockNumber) + require.Equal(t, uint64(1700000000), claim.BlockTimestamp) } // rootCallOutput ABI-encodes the bridge contract's getRoot() return value, like a JSON-RPC diff --git a/bridgetracker/types/status.go b/bridgetracker/types/status.go index 9533d66ab..55f8b09fc 100644 --- a/bridgetracker/types/status.go +++ b/bridgetracker/types/status.go @@ -265,10 +265,11 @@ type GERUpdateResult struct { } // InjectedGERResult is the result of StepWaitingGERInjection once it completes: the GER -// injected on the destination network that covers the bridge. The injection source does not -// expose the block it was injected in, unlike GERUpdateResult +// injected on the destination network that covers the bridge, and that injection's block type InjectedGERResult struct { - GER common.Hash `json:"ger"` + GER common.Hash `json:"ger"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp uint64 `json:"block_timestamp"` } // LERUpdateResult is the result of StepWaitingLERUpdate once it completes: the LER produced @@ -279,11 +280,12 @@ type LERUpdateResult struct { BlockNumber uint64 `json:"block_number"` } -// ClaimResult is the result of StepWaitingClaim once it completes: the claim transaction on +// ClaimResult is the result of StepClaimed once it completes: the claim transaction on // the destination network and the block it was mined in type ClaimResult struct { - ClaimTx common.Hash `json:"claim_tx"` - BlockNumber uint64 `json:"block_number"` + ClaimTx common.Hash `json:"claim_tx"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp uint64 `json:"block_timestamp"` } // L1SettledGERResult is the result of StepWaitL1SettledGER once it completes: the evidence, @@ -332,10 +334,15 @@ type GERData struct { LERType LERType `json:"ler_type"` // LERTypeString is the string representation of LERType, auto-populated on JSON marshaling LERTypeString string `json:"ler_type_string"` - // BlockNumber is the block where the GER update happened. Only populated when resolving - // the origin GER of an L1-originated bridge. Internal only: GERData is not serialized on - // any tracker response, it is the domain layer's currency to decide GER coverage + // BlockNumber is the block where the GER update happened. Populated when resolving the + // origin GER of an L1-originated bridge, and the GER injected on a bridge's destination + // network (see StepWaitingGERInjection's InjectedGERResult, which carries it on the wire). + // Internal only otherwise: GERData is not serialized on any tracker response, it is the + // domain layer's currency to decide GER coverage BlockNumber *uint64 `json:"-"` + // BlockTimestamp is BlockNumber's block timestamp. Populated (and carried on the wire) under + // the same conditions as BlockNumber + BlockTimestamp *uint64 `json:"-"` } // MarshalJSON is the implementation of the json.Marshaler interface. diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 66daea90c..85b8399b8 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -75,6 +75,78 @@ } } }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -192,7 +264,7 @@ ] }, "bridge_network_id": { - "description": "BridgeNetworkID is the network whose bridge service reported Bridge (its origin network)", + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", "type": "integer" }, "claim": { @@ -255,6 +327,34 @@ } } }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -341,7 +441,7 @@ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -438,16 +538,12 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, 1, 1000, 1000000, @@ -480,16 +576,12 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", "Second", "Minute", "Hour", - "minDuration", - "maxDuration", "Nanosecond", "Microsecond", "Millisecond", diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index c3d118f31..c4cb9d34f 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -2,6 +2,8 @@ The API is going to be an API REST: GET /tracker/v1/network/{network_id}/tx/{tx_hash} GET /tracker/v1/activity/from/{from_address} +GET /tracker/v1/bridge-address +GET /tracker/v1/bridge-address/{network_id} GET /tracker/v1/health In addition to the REST endpoint, a WebSocket endpoint is provided to receive bridge status updates as they happen (see [WebSocket](#websocket)). @@ -212,8 +214,8 @@ Carried in the `result` field of a [BridgeStepPath](#bridgesteppath). Its shape | PendingInclusion | `certificate_id` (Hash), `new_ler` (Hash), `previous_ler` (*Hash) | the certificate that first includes the bridge and the LER transition it produced; `previous_ler` is nil for a network's first certificate | | CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data once `status` is `done` | | WaitL1SettledGER | `tx_hash` (Hash), `block_number` (uint64), `ger` (Hash), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | -| WaitingGERInjection | `ger` (Hash) | GER injected on the destination network that covers the bridge; no block number, the injection source does not expose it | -| WaitingClaim | `claim_tx` (Hash), `block_number` (uint64) | claim transaction on the destination network and its block | +| WaitingGERInjection | `ger` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | GER injected on the destination network that covers the bridge, and that injection's block | +| Claimed | `claim_tx` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | claim transaction on the destination network, its block and that block's timestamp | | any other step | — | no result: always `nil` | ## ErrorStep @@ -373,8 +375,8 @@ sit alongside them (not nested inside) so the caller knows which bridge service | field | type | desc | | ------|------|------| -| bridge | BridgeResponse | raw bridge event, exactly as returned by the origin network's bridge service | -| bridge_network_id | uint32 | network whose bridge service reported `bridge` (its origin network) | +| bridge | BridgeResponse | raw bridge event, exactly as returned by the bridge service that reported it | +| bridge_network_id | uint32 | the network whose bridge service returned `bridge` — i.e. the network the bridge-creating tx was actually sent to. **Not** the same as `bridge.origin_network`, which is the origin network of the bridged *asset* and can differ when re-bridging an asset that itself originated on a third network | | claimed | string | bare string, tri-state result of the destination bridge contract's `isClaimed()` call the last time it was checked: `"false"` (confirmed unclaimed), `"true"` (claimed), or `"error"` if the check itself failed (e.g. no bridge contract address configured for the destination network) — callers must **not** read `"error"` as `"false"` | | claim_network_id | uint32 | network whose bridge service reported `claim` (the bridge's destination network); **omitted** (no key) until `claim` is present | | claim | ClaimResponse | raw claim record, exactly as returned by the destination network's bridge service, once `claimed` is `"true"` and the indexer has recorded it; **omitted** (no key) until then | @@ -522,6 +524,63 @@ Example (one claimed bridge, one still-pending bridge with `?includeTracking=tru - **Idle eviction**: a `from_address` nobody has asked about in `Tracker.ActivityIdleTimeout` (default 30 minutes, same idea as the main endpoint's `IdleTimeout`) is forgotten entirely on the next request for it — everything cached for it (bridges, claim state) is freed, and it starts fresh exactly as if it were being queried for the first time. - **`includeTracking=true` registers, it does not wait**: unlike the main tracker endpoint, this does not wait for the tracking engine's first resolution attempt — it registers the bridge (if not already registered) and reports whatever `TrackingData` snapshot is available right away, which may still be the bare `"registered"` state. +## Bridge Address + +GET /tracker/v1/bridge-address + +GET /tracker/v1/bridge-address/{network_id} + +Reports the bridge contract address of one network, or of **every network the tracker currently +knows about** (via the bridge service finder), without needing a fixed config list. With no +`network_id` the body is a [BridgeAddressResponse](#bridgeaddressresponse); with `network_id` the +body is a single [BridgeAddressItem](#bridgeaddressitem). + +Request: + +| param | location | type | mandatory | desc | +| ------|----------|------|-----------|------| +| network_id | path | uint32 | no | network to look up; omit to get every network | + +### Behavior + +- `200 OK` — the body is a [BridgeAddressResponse](#bridgeaddressresponse) (no `network_id`) or a [BridgeAddressItem](#bridgeaddressitem) (`network_id` given). +- `400 Bad Request` — `network_id` is not a uint32: the body is an [ErrorData](#errordata). +- `500 Internal Server Error` — resolving the bridge contract address failed (e.g. the on-chain rollup manager lookup failed): the body is an [ErrorData](#errordata). +- **This endpoint is opt-in**: it only exists if the binary is configured with a bridge address resolver (`Config.BridgeAddressResolver`); otherwise both routes are not registered at all (plain `404`). + +### BridgeAddressResponse + +| field | type | desc | +| ------|------|------| +| bridges | BridgeAddressItem [] | the bridge contract address of every network the tracker currently knows about | + +### BridgeAddressItem + +| field | type | desc | +| ------|------|------| +| network_id | uint32 | the network `bridge_address` belongs to | +| bridge_address | Address | the bridge contract address on `network_id` | + +Example, `GET /bridge-address`: + +```json +{ + "bridges": [ + { "network_id": 0, "bridge_address": "0x1111111111111111111111111111111111111111" }, + { "network_id": 1, "bridge_address": "0x2222222222222222222222222222222222222222" } + ] +} +``` + +Example, `GET /bridge-address/1`: + +```json +{ + "network_id": 1, + "bridge_address": "0x2222222222222222222222222222222222222222" +} +``` + ## WebSocket Endpoint to subscribe to a bridge and receive its status updates as they happen, instead of polling the REST endpoint. diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 1270f47ab..032d81349 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -196,6 +196,11 @@ func runTracker( trackerCfg.ActivityScanner = activitySource trackerCfg.ActivityClaims = activitySource + // GET /bridge-address[/{network_id}] resolves the bridge contract address of one network, + // or every network the finder currently knows about; finder satisfies + // bridgetracker.BridgeAddressResolver directly (NetworkIDs/BridgeAddress) + trackerCfg.BridgeAddressResolver = finder + tracker := bridgetracker.New(&trackerCfg) engine, err := bridgetracker.NewEngine( @@ -212,6 +217,7 @@ func runTracker( GERs: gerSource, WaitingGERUpdateSource: gerSource, LERs: sources.NewLERSource(rpcClients), + ClaimChecker: sources.NewClaimChecker(finder, rpcClients), Claims: sources.NewClaimSource(finder), Settlement: sources.NewSettlementSource( rpcClients, trackerCfg.L1BlockFinality, trackerCfg.L1GlobalExitRootAddress), From 7ab9701c2f9915cc1975efdeee91a2de8a79c43c Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:25:24 +0200 Subject: [PATCH 10/16] fix(bridgetracker): gate certificate settlement on L1 tx visibility A certificate can flip to Settled in the agglayer before its settlement tx is actually visible on L1, letting StepCertificatePending resolve early. - CertificateSource now resolves the settlement tx's block number/timestamp on L1 (settlementBlockInfo) and exposes them as CertificateData.BlockNumber/ BlockTimestamp; both stay nil while the receipt is not mined/visible yet. - CertificatePendingResolver only treats the step as done once the certificate is settled AND BlockNumber is known, otherwise it keeps returning ErrCertificateNotSettled. - SettlementSource now also surfaces SettlementBlockTimestamp/ GERBlockTimestamp on L1SettledGERResult. - Wire the new EthClientResolver dependency into NewCertificateSource (proxy/cmd/run.go). - Update docs/bridgetracker/API.md and tests accordingly. Co-Authored-By: Claude Sonnet 5 --- bridgetracker/api/bridge_step_path_test.go | 24 ++++++- .../resolve_step_certificate_pending.go | 11 ++-- bridgetracker/domain/resolve_steps_test.go | 21 +++++- bridgetracker/engine_test.go | 6 ++ bridgetracker/sources/certificate.go | 63 ++++++++++++++++-- bridgetracker/sources/certificate_test.go | 63 ++++++++++++++---- bridgetracker/sources/settlement.go | 31 ++++++--- bridgetracker/sources/settlement_test.go | 64 ++++++++++++++----- bridgetracker/types/status.go | 16 +++-- docs/bridgetracker/API.md | 10 ++- proxy/cmd/run.go | 2 +- 11 files changed, 252 insertions(+), 59 deletions(-) diff --git a/bridgetracker/api/bridge_step_path_test.go b/bridgetracker/api/bridge_step_path_test.go index 163b2aaa8..69f7966fc 100644 --- a/bridgetracker/api/bridge_step_path_test.go +++ b/bridgetracker/api/bridge_step_path_test.go @@ -134,6 +134,22 @@ func TestBridgeStepPathResultMarshalJSON(t *testing.T) { }, expected: `{"certificate_id":"0x0000000000000000000000000000000000000000000000000000000000000001","status":4,"status_string":"Settled"}`, }, + { + name: "certificate data result, settled with its L1 block known", + result: &types.CertificateData{ + CertificateID: common.HexToHash("0x01"), + Status: agglayertypes.Settled, + BlockNumber: func() *uint64 { n := uint64(400); return &n }(), + BlockTimestamp: func() *uint64 { ts := uint64(1700000400); return &ts }(), + }, + expected: `{ + "certificate_id":"0x0000000000000000000000000000000000000000000000000000000000000001", + "status":4, + "status_string":"Settled", + "block_number":400, + "block_timestamp":1700000400 + }`, + }, { name: "claim result", result: &types.ClaimResult{ @@ -145,16 +161,20 @@ func TestBridgeStepPathResultMarshalJSON(t *testing.T) { { name: "L1 settled GER result", result: &types.L1SettledGERResult{ - TxHash: common.HexToHash("0x0d"), SettlementBlockNumber: 400, SettlementLogIndex: 1, - GER: common.HexToHash("0x0e"), GERBlockNumber: 400, GERLogIndex: 2, + TxHash: common.HexToHash("0x0d"), SettlementBlockNumber: 400, + SettlementBlockTimestamp: 1700000000, SettlementLogIndex: 1, + GER: common.HexToHash("0x0e"), GERBlockNumber: 400, + GERBlockTimestamp: 1700000000, GERLogIndex: 2, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, expected: `{ "tx_hash":"0x000000000000000000000000000000000000000000000000000000000000000d", "settlement_block_number":400, + "settlement_block_timestamp":1700000000, "settlement_log_index":1, "ger":"0x000000000000000000000000000000000000000000000000000000000000000e", "ger_block_number":400, + "ger_block_timestamp":1700000000, "ger_log_index":2, "has_verify_batches_trusted_aggregator":true, "has_update_l1_info_tree":true, diff --git a/bridgetracker/domain/resolve_step_certificate_pending.go b/bridgetracker/domain/resolve_step_certificate_pending.go index f47df8080..ccc06ad9f 100644 --- a/bridgetracker/domain/resolve_step_certificate_pending.go +++ b/bridgetracker/domain/resolve_step_certificate_pending.go @@ -9,7 +9,9 @@ import ( ) // ErrCertificateNotSettled means the bridge already has a certificate but it has not settled -// yet: the same "not ready" family as ErrStepPending (errors.Is matches both), but carries the +// yet — or it has, but its settlement tx is not visible on L1 yet (see CertificateSource. +// settlementBlockInfo, which can lag a tick behind the certificate itself turning Settled): the +// same "not ready" family as ErrStepPending (errors.Is matches both), but carries the // certificate's current status as its Result so clients can see it progress while they wait, // instead of only once it settles var ErrCertificateNotSettled = fmt.Errorf("certificate not settled yet: %w", ErrStepPending) @@ -25,7 +27,8 @@ type CertificateSource interface { // CertificatePendingResolver resolves StepCertificatePending: covers every status the // certificate goes through — Pending, Proven, Candidate or InError all park here, only its -// Result changes — until it settles, the only transition that moves the bridge on +// Result changes — until it settles AND its settlement tx's block is visible on L1 +// (CertificateData.BlockNumber/BlockTimestamp), the transition that moves the bridge on type CertificatePendingResolver struct { port CertificateSource } @@ -47,8 +50,8 @@ func (r *CertificatePendingResolver) Resolve( return nil, ErrStepPending } - if cert.Status.IsSettled() { + if cert.Status.IsSettled() && cert.BlockNumber != nil { return &cert.CertificateData, nil } - return &cert.CertificateData, ErrCertificateNotSettled // still awaiting settlement + return &cert.CertificateData, ErrCertificateNotSettled // still awaiting settlement, or its L1 block } diff --git a/bridgetracker/domain/resolve_steps_test.go b/bridgetracker/domain/resolve_steps_test.go index 95e20c596..bb605df8a 100644 --- a/bridgetracker/domain/resolve_steps_test.go +++ b/bridgetracker/domain/resolve_steps_test.go @@ -149,7 +149,12 @@ func TestResolveSteps(t *testing.T) { originLER := &types.LERUpdateResult{NetworkID: 1, LER: common.Hash{2}, BlockNumber: 200} injectedGER := &types.GERData{NetworkID: 2, GER: &common.Hash{1}} settlementTxHash := common.Hash{4} - settledCertData := types.CertificateData{Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash} + settledCertBlockNumber := uint64(400) + settledCertBlockTimestamp := uint64(1700000400) + settledCertData := types.CertificateData{ + Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledCertBlockNumber, BlockTimestamp: &settledCertBlockTimestamp, + } settledCert := &types.CertificateInclusionData{CertificateData: settledCertData} settlementLeafIndex := uint32(7) settlementResult := &types.L1SettledGERResult{ @@ -449,8 +454,13 @@ func TestResolveStepsErrors(t *testing.T) { now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) originLER := &types.LERUpdateResult{NetworkID: 1, LER: common.Hash{2}, BlockNumber: 200} settlementTxHash := common.Hash{4} + settledCertBlockNumber := uint64(400) + settledCertBlockTimestamp := uint64(1700000400) settledCert := &types.CertificateInclusionData{ - CertificateData: types.CertificateData{Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash}, + CertificateData: types.CertificateData{ + Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledCertBlockNumber, BlockTimestamp: &settledCertBlockTimestamp, + }, } settlementLeafIndex := uint32(7) settlementResult := &types.L1SettledGERResult{ @@ -793,8 +803,13 @@ func TestCertificateResolverSkipsWaypoints(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, t1) + settledBlockNumber := uint64(400) + settledBlockTimestamp := uint64(1700000400) cert := &types.CertificateInclusionData{ - CertificateData: types.CertificateData{CertificateID: common.Hash{9}, Status: agglayertypes.Settled}, + CertificateData: types.CertificateData{ + CertificateID: common.Hash{9}, Status: agglayertypes.Settled, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, + }, } facts := &fakeFacts{certificate: cert} diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 21f5981ba..85b5d1015 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -530,10 +530,13 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { require.Equal(t, &f.cert.CertificateData, inError.AllSteps()[*inError.StepIndex()].Result(), "the certificate's current, not yet settled, status is visible while waiting") + settledBlockNumber := uint64(1500) + settledBlockTimestamp := uint64(1700001500) f.cert = &types.CertificateInclusionData{ CertificateData: types.CertificateData{ CertificateID: common.HexToHash("0x02"), Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, }, } engine.tick(t.Context()) @@ -619,10 +622,13 @@ func TestEngineIncrementalResolution(t *testing.T) { // walk the bridge up to WaitingClaim: every milestone but the claim is done f.originLER = &types.LERUpdateResult{NetworkID: 1, LER: common.HexToHash("0x0a"), BlockNumber: 10} + settledBlockNumber := uint64(1500) + settledBlockTimestamp := uint64(1700001500) f.cert = &types.CertificateInclusionData{ CertificateData: types.CertificateData{ CertificateID: common.HexToHash("0x01"), Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, }, } settlementLeafIndex := uint32(7) diff --git a/bridgetracker/sources/certificate.go b/bridgetracker/sources/certificate.go index bc6b924b1..1b4ff7d4e 100644 --- a/bridgetracker/sources/certificate.go +++ b/bridgetracker/sources/certificate.go @@ -2,12 +2,14 @@ package sources import ( "context" + "errors" "fmt" agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/bridgetracker" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" ) @@ -28,16 +30,21 @@ type CertificateSource struct { // services resolves bridge.NetworkID's own aggkit bridge service, used to translate a // certificate's NewLocalExitRoot into a deposit-count position (see rootIndexFor) services *bridgeServiceClients - logger aggkitcommon.Logger + // clients resolves L1's JSON-RPC client, used to locate a settled certificate's settlement + // tx once it is visible there (see settlementBlockInfo) + clients EthClientResolver + logger aggkitcommon.Logger } // NewCertificateSource returns a CertificateSource fetching certificate headers through client, -// and resolving local exit root positions through the per-network bridge service clients finder -// resolves +// resolving local exit root positions through the per-network bridge service clients finder +// resolves, and locating settlement txs on L1 through clients func NewCertificateSource( - client CertificateHeaderClient, finder NetworkURLResolver, logger aggkitcommon.Logger, + client CertificateHeaderClient, finder NetworkURLResolver, clients EthClientResolver, logger aggkitcommon.Logger, ) *CertificateSource { - return &CertificateSource{client: client, services: newBridgeServiceClients(finder), logger: logger} + return &CertificateSource{ + client: client, services: newBridgeServiceClients(finder), clients: clients, logger: logger, + } } // CertificateFor implements bridgetracker.CertificateSource: it resolves the certificate that @@ -125,7 +132,9 @@ func (s *CertificateSource) rootIndexFor(ctx context.Context, networkID uint32, } // certificateHeaderFor fetches certificateID's current header from the agglayer and maps it -// into the tracker's trackertypes.CertificateInclusionData +// into the tracker's trackertypes.CertificateInclusionData. Once the certificate is settled, +// also resolves its settlement tx's block on L1 (see settlementBlockInfo) — nil/nil while that +// tx is not visible there yet, which CertificatePendingResolver treats as still pending func (s *CertificateSource) certificateHeaderFor( ctx context.Context, certificateID common.Hash, ) (*trackertypes.CertificateInclusionData, error) { @@ -138,6 +147,15 @@ func (s *CertificateSource) certificateHeaderFor( if header.Error != nil { errMsg = header.Error.Error() } + + var blockNumber, blockTimestamp *uint64 + if header.Status.IsSettled() && header.SettlementTxHash != nil { + blockNumber, blockTimestamp, err = s.settlementBlockInfo(ctx, *header.SettlementTxHash) + if err != nil { + return nil, err + } + } + s.logger.Debugf("certificate %s status: %s (settlementTxHash=%s, error=%q)", certificateID, header.Status, header.SettlementTxHash, errMsg) return &trackertypes.CertificateInclusionData{ @@ -146,8 +164,41 @@ func (s *CertificateSource) certificateHeaderFor( Status: header.Status, Error: errMsg, SettlementTxHash: header.SettlementTxHash, + BlockNumber: blockNumber, + BlockTimestamp: blockTimestamp, }, PreviousLocalExitRoot: header.PreviousLocalExitRoot, NewLocalExitRoot: header.NewLocalExitRoot, }, nil } + +// settlementBlockInfo resolves settlementTxHash's block number and timestamp on L1, or nil/nil +// if its receipt is not visible there yet: unlike SettlementSource (StepWaitL1SettledGER), this +// is not gated by L1 finality — it only needs to know where the tx landed, not to validate what +// it did there +func (s *CertificateSource) settlementBlockInfo( + ctx context.Context, settlementTxHash common.Hash, +) (*uint64, *uint64, error) { + client, err := s.clients.RPCClientFor(ctx, 0) // a certificate always settles on L1 + if err != nil { + return nil, nil, fmt.Errorf("resolving L1 JSON-RPC client: %w", err) + } + + receipt, err := client.TransactionReceipt(ctx, settlementTxHash) + if errors.Is(err, ethereum.NotFound) { + return nil, nil, nil // not mined/visible on L1 yet + } + if err != nil { + return nil, nil, fmt.Errorf("fetching settlement tx receipt %s: %w", settlementTxHash, err) + } + if receipt.BlockNumber == nil { + return nil, nil, nil // defensive: a mined receipt always carries one, but just in case + } + + timestamp, err := blockTimestamp(ctx, client, receipt.BlockHash) + if err != nil { + return nil, nil, err + } + number := receipt.BlockNumber.Uint64() + return &number, ×tamp, nil +} diff --git a/bridgetracker/sources/certificate_test.go b/bridgetracker/sources/certificate_test.go index aa8dc0182..77d261df6 100644 --- a/bridgetracker/sources/certificate_test.go +++ b/bridgetracker/sources/certificate_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/big" "net/http" "net/http/httptest" "testing" @@ -11,7 +12,11 @@ import ( agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/bridgeservicefinder" "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/types/mocks" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -85,7 +90,12 @@ func TestCertificateSourceCertificateHeaderFor(t *testing.T) { PreviousLocalExitRoot: &previousLER, NewLocalExitRoot: newLER, }} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, + }, nil) + expectBlockTimestamp(client) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), StaticClients{0: client}, testLogger) cert, err := source.certificateHeaderFor(t.Context(), certID) require.NoError(t, err) @@ -95,11 +105,35 @@ func TestCertificateSourceCertificateHeaderFor(t *testing.T) { require.Empty(t, cert.Error) require.Equal(t, &previousLER, cert.PreviousLocalExitRoot) require.Equal(t, newLER, cert.NewLocalExitRoot) + require.NotNil(t, cert.BlockNumber) + require.Equal(t, uint64(12345), *cert.BlockNumber) + require.NotNil(t, cert.BlockTimestamp) + require.Equal(t, testBlockTimestamp, *cert.BlockTimestamp) +} + +// TestCertificateSourceCertificateHeaderForSettlementTxNotMinedYet pins that a settled +// certificate whose settlement tx is not visible on L1 yet resolves with BlockNumber/ +// BlockTimestamp left nil, rather than erroring: CertificatePendingResolver treats that as +// still pending (see ErrCertificateNotSettled) +func TestCertificateSourceCertificateHeaderForSettlementTxNotMinedYet(t *testing.T) { + certID := common.HexToHash("0x0f") + settlementTx := common.HexToHash("0x10") + fake := &fakeCertificateHeaderClient{header: &agglayertypes.CertificateHeader{ + CertificateID: certID, Status: agglayertypes.Settled, SettlementTxHash: &settlementTx, + }} + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(nil, ethereum.NotFound) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), StaticClients{0: client}, testLogger) + + cert, err := source.certificateHeaderFor(t.Context(), certID) + require.NoError(t, err) + require.Nil(t, cert.BlockNumber) + require.Nil(t, cert.BlockTimestamp) } func TestCertificateSourceCertificateHeaderForTransientError(t *testing.T) { fake := &fakeCertificateHeaderClient{err: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateHeaderFor(t.Context(), common.HexToHash("0x0f")) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -113,7 +147,7 @@ func TestCertificateIDForSettledCovers(t *testing.T) { fake := &fakeCertificateHeaderClient{ settled: &agglayertypes.CertificateHeader{CertificateID: certID, NewLocalExitRoot: ler}, } - source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -132,7 +166,7 @@ func TestCertificateIDForSettledNotCoveredButPendingSurfaced(t *testing.T) { } // pending's root deliberately does not cover bridge (index 3 < DepositCount 7) either: it // must still be surfaced (see certificateIDFor's doc) since it is not settled/terminal - source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5, pendingLER.Hex(): 3}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5, pendingLER.Hex(): 3}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -148,7 +182,7 @@ func TestCertificateIDForSettledNotCoveredNoPending(t *testing.T) { } // A settled-but-non-covering certificate must never be returned: CertificatePendingResolver // treats Settled as "done", so this would make the tracker think the step completed early - source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -158,7 +192,7 @@ func TestCertificateIDForSettledNotCoveredNoPending(t *testing.T) { func TestCertificateIDForNoSettledNoPending(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -173,7 +207,7 @@ func TestCertificateIDForNoSettledPendingSurfaced(t *testing.T) { fake := &fakeCertificateHeaderClient{ pending: &agglayertypes.CertificateHeader{CertificateID: pendingCertID, NewLocalExitRoot: pendingLER}, } - source := NewCertificateSource(fake, fakeRootIndexes{pendingLER.Hex(): 7}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{pendingLER.Hex(): 7}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -183,7 +217,7 @@ func TestCertificateIDForNoSettledPendingSurfaced(t *testing.T) { func TestCertificateIDForSettledErrorPropagates(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{settledErr: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -192,7 +226,7 @@ func TestCertificateIDForSettledErrorPropagates(t *testing.T) { func TestCertificateIDForPendingErrorPropagates(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{pendingErr: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -207,7 +241,7 @@ func TestCertificateIDForRootNotSyncedYetIsTransient(t *testing.T) { } // the bridge service on bridge.NetworkID has not synced settledLER yet: retried by the // engine, not treated as "not covered" - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.Error(t, err) @@ -216,7 +250,7 @@ func TestCertificateIDForRootNotSyncedYetIsTransient(t *testing.T) { func TestCertificateForNotCovered(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) cert, err := source.CertificateFor(t.Context(), bridge) require.NoError(t, err) @@ -237,7 +271,12 @@ func TestCertificateForCovered(t *testing.T) { SettlementTxHash: &settlementTx, }, } - source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), testLogger) + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, + }, nil) + expectBlockTimestamp(client) + source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), StaticClients{0: client}, testLogger) cert, err := source.CertificateFor(t.Context(), bridge) require.NoError(t, err) diff --git a/bridgetracker/sources/settlement.go b/bridgetracker/sources/settlement.go index b2821e6bb..0a7e31c47 100644 --- a/bridgetracker/sources/settlement.go +++ b/bridgetracker/sources/settlement.go @@ -97,9 +97,15 @@ func (s *SettlementSource) SettlementGERUpdate( return nil, nil // mined, but not yet at the required finality } + settlementBlockTimestamp, err := blockTimestamp(ctx, client, receipt.BlockHash) + if err != nil { + return nil, err + } + result := &trackertypes.L1SettledGERResult{ - TxHash: settlementTxHash, - SettlementBlockNumber: receipt.BlockNumber.Uint64(), + TxHash: settlementTxHash, + SettlementBlockNumber: receipt.BlockNumber.Uint64(), + SettlementBlockTimestamp: settlementBlockTimestamp, } for _, l := range receipt.Logs { if len(l.Topics) == 0 { @@ -120,6 +126,8 @@ func (s *SettlementSource) SettlementGERUpdate( result.HasUpdateL1InfoTree = true result.GER = crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]) result.GERBlockNumber = receipt.BlockNumber.Uint64() + // same block as the settlement tx itself, so its timestamp is already known + result.GERBlockTimestamp = settlementBlockTimestamp result.GERLogIndex = l.Index case updateL1InfoTreeV2Signature: result.HasUpdateL1InfoTreeV2 = true @@ -151,6 +159,7 @@ func (s *SettlementSource) SettlementGERUpdate( } result.GER = event.GER result.GERBlockNumber = event.BlockNumber + result.GERBlockTimestamp = event.BlockTimestamp result.GERLogIndex = event.LogIndex } @@ -160,9 +169,10 @@ func (s *SettlementSource) SettlementGERUpdate( // updateL1InfoTreeEvent is the GER-relevant evidence of a single UpdateL1InfoTree event: the // GER it produced and where on L1 it landed type updateL1InfoTreeEvent struct { - GER common.Hash - BlockNumber uint64 - LogIndex uint + GER common.Hash + BlockNumber uint64 + BlockTimestamp uint64 + LogIndex uint } // findEventUpdateL1InfoTreeBackwards looks for the most recent UpdateL1InfoTree event on the @@ -202,10 +212,15 @@ func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( last.BlockNumber, domain.ErrBadSettlementTx) } mainnetExitRoot, rollupExitRoot := last.Topics[1], last.Topics[2] + timestamp, err := blockTimestamp(ctx, client, last.BlockHash) + if err != nil { + return nil, err + } return &updateL1InfoTreeEvent{ - GER: crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]), - BlockNumber: last.BlockNumber, - LogIndex: last.Index, + GER: crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]), + BlockNumber: last.BlockNumber, + BlockTimestamp: timestamp, + LogIndex: last.Index, }, nil } diff --git a/bridgetracker/sources/settlement_test.go b/bridgetracker/sources/settlement_test.go index 8a31e1ac9..d77eb032f 100644 --- a/bridgetracker/sources/settlement_test.go +++ b/bridgetracker/sources/settlement_test.go @@ -53,22 +53,40 @@ var ( } ) +// testBackwardsBlockHash/testBackwardsBlockTimestamp stub the block hash/timestamp of the +// earlier UpdateL1InfoTree event findEventUpdateL1InfoTreeBackwards finds, in tests where it +// lands in a different block than the settlement tx itself (testBlockHash/testBlockTimestamp, +// from sources_test.go, stand for the settlement tx's own block) +var ( + testBackwardsBlockHash = common.HexToHash("0xba0000000000000000000000000000000000000000000000000000000000ba") + testBackwardsBlockTimestamp = uint64(1690000000) +) + +// expectBackwardsBlockTimestamp stubs client's HeaderByHash for testBackwardsBlockHash to +// report testBackwardsBlockTimestamp +func expectBackwardsBlockTimestamp(client *mocks.BaseEthereumClienter) { + client.EXPECT().HeaderByHash(mock.Anything, testBackwardsBlockHash). + Return(&gethtypes.Header{Time: testBackwardsBlockTimestamp}, nil) +} + func TestSettlementSourceBothMandatoryEventsPresent(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) require.Equal(t, &trackertypes.L1SettledGERResult{ - TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, GERBlockNumber: 12345, + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12345, GERBlockTimestamp: testBlockTimestamp, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, result) } @@ -80,7 +98,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { leafCount := uint64(8) client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, @@ -90,6 +108,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) @@ -105,7 +124,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, @@ -113,6 +132,7 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) @@ -128,24 +148,26 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { func TestSettlementSourceMalformedUpdateL1InfoTreeLog(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, {Topics: []common.Hash{updateL1InfoTreeSignature}}, }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ - BlockNumber: 12000, Index: 3, + BlockNumber: 12000, Index: 3, BlockHash: testBackwardsBlockHash, Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, }) + expectBackwardsBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) require.Equal(t, &trackertypes.L1SettledGERResult{ - TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, - GERBlockNumber: 12000, GERLogIndex: 3, + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12000, GERBlockTimestamp: testBackwardsBlockTimestamp, GERLogIndex: 3, HasVerifyBatchesTrustedAggregator: true, }, result) } @@ -171,9 +193,10 @@ func TestSettlementSourceMissingVerifyBatchesTrustedAggregator(t *testing.T) { t.Run(tc.name, func(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), Logs: tc.logs, + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: tc.logs, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) @@ -190,23 +213,25 @@ func TestSettlementSourceMissingVerifyBatchesTrustedAggregator(t *testing.T) { func TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ - BlockNumber: 12000, Index: 3, + BlockNumber: 12000, Index: 3, BlockHash: testBackwardsBlockHash, Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, }) + expectBackwardsBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) require.Equal(t, &trackertypes.L1SettledGERResult{ - TxHash: testTxHash, SettlementBlockNumber: 12345, GER: wantGER, - GERBlockNumber: 12000, GERLogIndex: 3, + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12000, GERBlockTimestamp: testBackwardsBlockTimestamp, GERLogIndex: 3, HasVerifyBatchesTrustedAggregator: true, }, result) } @@ -217,12 +242,13 @@ func TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent(t *testi func TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(5000), + BlockNumber: big.NewInt(5000), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, }, }, nil) expectFinalized(client, 5000) + expectBlockTimestamp(client) client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), Addresses: []common.Address{testGERAddress}, @@ -250,12 +276,18 @@ func TestFindEventUpdateL1InfoTreeBackwardsPaginates(t *testing.T) { Addresses: []common.Address{testGERAddress}, Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, }).Return([]gethtypes.Log{ - {BlockNumber: 10000, Index: 2, Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}}, + { + BlockNumber: 10000, Index: 2, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, }, nil) + expectBackwardsBlockTimestamp(client) event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 25000) require.NoError(t, err) - require.Equal(t, &updateL1InfoTreeEvent{GER: wantGER, BlockNumber: 10000, LogIndex: 2}, event) + require.Equal(t, &updateL1InfoTreeEvent{ + GER: wantGER, BlockNumber: 10000, BlockTimestamp: testBackwardsBlockTimestamp, LogIndex: 2, + }, event) } // TestFindEventUpdateL1InfoTreeBackwardsNotFound pins that reaching block 0 without a match is diff --git a/bridgetracker/types/status.go b/bridgetracker/types/status.go index 55f8b09fc..eac59cbf3 100644 --- a/bridgetracker/types/status.go +++ b/bridgetracker/types/status.go @@ -292,12 +292,12 @@ type ClaimResult struct { // read off the certificate's settlement tx receipt on L1, that the settlement propagated to // the L1 Global Exit Root. HasVerifyBatchesTrustedAggregator and HasUpdateL1InfoTree are both // required for the step to complete; HasUpdateL1InfoTreeV2 is only informational. -// SettlementBlockNumber/SettlementLogIndex locate the settlement tx's own -// VerifyBatchesTrustedAggregator log — the event that confirms this tx is a genuine +// SettlementBlockNumber/SettlementBlockTimestamp/SettlementLogIndex locate the settlement tx's +// own VerifyBatchesTrustedAggregator log — the event that confirms this tx is a genuine // certificate settlement. GER is the Global Exit Root produced by the settlement (computed // from UpdateL1InfoTree's mainnet/rollup exit roots), used by StepWaitingGERInjection to check -// whether it has reached the destination. GERBlockNumber/GERLogIndex locate the -// UpdateL1InfoTree event GER was computed from: normally the same block as the settlement +// whether it has reached the destination. GERBlockNumber/GERBlockTimestamp/GERLogIndex locate +// the UpdateL1InfoTree event GER was computed from: normally the same block as the settlement // (HasUpdateL1InfoTree true), but when the settlement tx's own receipt does not carry the // event (the settlement did not move the GER itself), they instead point to the closest // earlier one on L1 (see sources.SettlementSource.findEventUpdateL1InfoTreeBackwards), whose @@ -308,9 +308,11 @@ type ClaimResult struct { type L1SettledGERResult struct { TxHash common.Hash `json:"tx_hash"` SettlementBlockNumber uint64 `json:"settlement_block_number"` + SettlementBlockTimestamp uint64 `json:"settlement_block_timestamp"` SettlementLogIndex uint `json:"settlement_log_index"` GER common.Hash `json:"ger"` GERBlockNumber uint64 `json:"ger_block_number"` + GERBlockTimestamp uint64 `json:"ger_block_timestamp"` GERLogIndex uint `json:"ger_log_index"` L1InfoTreeIndex *uint32 `json:"l1_info_tree_index,omitempty"` HasVerifyBatchesTrustedAggregator bool `json:"has_verify_batches_trusted_aggregator"` @@ -362,6 +364,12 @@ type CertificateData struct { // Error is only set if the certificate carries an error message (relevant for InError certs) Error string `json:"error,omitempty"` SettlementTxHash *common.Hash `json:"settlement_tx_hash,omitempty"` + // BlockNumber/BlockTimestamp locate SettlementTxHash on L1, once its receipt is visible + // there. Only ever set once Status.IsSettled(): even then, both stay nil for a transient + // tick (the settlement tx's own receipt can lag a step behind the certificate turning + // Settled — see CertificatePendingResolver), so nil is not necessarily permanent + BlockNumber *uint64 `json:"block_number,omitempty"` + BlockTimestamp *uint64 `json:"block_timestamp,omitempty"` } // MarshalJSON is the implementation of the json.Marshaler interface. diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index c4cb9d34f..e1fe99c99 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -212,8 +212,8 @@ Carried in the `result` field of a [BridgeStepPath](#bridgesteppath). Its shape | WaitingGERUpdate | `l1_info_tree_index` (uint32), `ger` (Hash), `mer` (Hash), `rer` (Hash), `block_number` (uint64), `block_timestamp` (uint64), `log_index` (uint) | GER resulting from the update on L1, the L1 info tree leaf index it landed at, and the block where it was updated | | WaitingLERUpdate | `network_id` (uint32), `ler` (Hash), `block_number` (uint64) | LER resulting from the update on the origin L2 and the block where it was updated | | PendingInclusion | `certificate_id` (Hash), `new_ler` (Hash), `previous_ler` (*Hash) | the certificate that first includes the bridge and the LER transition it produced; `previous_ler` is nil for a network's first certificate | -| CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data once `status` is `done` | -| WaitL1SettledGER | `tx_hash` (Hash), `block_number` (uint64), `ger` (Hash), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | +| CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data — including `block_number`/`block_timestamp` — once `status` is `done` | +| WaitL1SettledGER | `tx_hash` (Hash), `settlement_block_number` (uint64), `settlement_block_timestamp` (uint64), `settlement_log_index` (uint), `ger` (Hash), `ger_block_number` (uint64), `ger_block_timestamp` (uint64), `ger_log_index` (uint), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots, and `ger_block_number`/`ger_block_timestamp`/`ger_log_index` locate the event it was computed from — normally the same block as the settlement, but the closest earlier one on L1 when the settlement tx's own receipt didn't move the GER itself. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | | WaitingGERInjection | `ger` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | GER injected on the destination network that covers the bridge, and that injection's block | | Claimed | `claim_tx` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | claim transaction on the destination network, its block and that block's timestamp | | any other step | — | no result: always `nil` | @@ -263,6 +263,8 @@ bare string, see the note at the top of [Response types](#response-types). | status_string | string | string representation of status (e.g. "Settled") | error | string | Only set if the proto carries `Error.Message` (relevant for `InError` certs); **omitted** (no key) otherwise | | settlement_tx_hash | *Hash | Set once the certificate has a settlement tx (normally only from `Settled` onward); **omitted** (no key), not `null`, before that | +| block_number | *uint64 | The L1 block `settlement_tx_hash` was mined in; **omitted** (no key), not `null`, until it is visible there — which can lag a tick behind `status` turning `Settled` | +| block_timestamp | *uint64 | `block_number`'s timestamp; same omit/lag rules as `block_number` | Example (settled certificate, as it appears in `all_steps[i].result` for `CertificatePending`): @@ -271,7 +273,9 @@ Example (settled certificate, as it appears in `all_steps[i].result` for `Certif "certificate_id": "0x0000000000000000000000000000000000000000000000000000000000000001", "status": 4, "status_string": "Settled", - "settlement_tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002" + "settlement_tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002", + "block_number": 400, + "block_timestamp": 1700000400 } ``` diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 032d81349..e696760e6 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -213,7 +213,7 @@ func runTracker( bridgetracker.EngineSources{ Bridges: bridgeEvents, Certificates: sources.NewCertificateSource( - agglayerClient, finder, log.WithFields("module", "bridgetracker-certificatesource")), + agglayerClient, finder, rpcClients, log.WithFields("module", "bridgetracker-certificatesource")), GERs: gerSource, WaitingGERUpdateSource: gerSource, LERs: sources.NewLERSource(rpcClients), From aa1413410c9fced0f7dcc396f6147343f01575ed Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:28:18 +0200 Subject: [PATCH 11/16] docs(bridgetracker): clarify L2GlobalExitRootAddrs is a workaround-only fallback Document that Tracker.L2GlobalExitRootAddrs should only be set for a destination network whose bridge-service instance does not report the L2 block a covering GER was injected at. Co-Authored-By: Claude Sonnet 5 --- docs/bridgetracker.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/bridgetracker.md b/docs/bridgetracker.md index 1569a36aa..5ba02b870 100644 --- a/docs/bridgetracker.md +++ b/docs/bridgetracker.md @@ -74,6 +74,11 @@ L1BlockFinality = "LatestBlock" L2BlockFinality = "LatestBlock" MaxTrackedBridges = 100000 +# Workaround only: uncomment for a destination network whose bridge-service instance does not +# report the L2 block a covering GER was injected at. +# [Tracker.L2GlobalExitRootAddrs] +# 1 = "0x..." + [Tracker.AgglayerClient] Cached = true [Tracker.AgglayerClient.ConfigurationCache] @@ -104,6 +109,12 @@ UseTLS = false - `MaxTrackedBridges`: caps the in-memory supervised list; a request beyond it fails instead of registering the bridge — reaching the cap never evicts an existing entry to make room, so `RetentionPeriod` and `IdleTimeout` are what keep the registry under it during normal operation. +- `L2GlobalExitRootAddrs`: **workaround only** — a networkID → `GlobalExitRootManagerL2` contract + address map, used solely as a fallback for a destination network whose bridge-service instance + does not report the L2 block a covering GER was actually injected at. For a network present + here, the tracker scans that network's own L2 for the `UpdateHashChainValue` event instead of + leaving it absent. A network absent from this map (the default, empty map) never gets this + fallback attempted; it should not be set otherwise. - `AgglayerClient`: the client used to resolve an L2-originated bridge's covering certificate and its status (`PendingInclusion`/`CertificatePending`/`WaitL1SettledGER`). `Cached` is the master switch for `ConfigurationCache`'s per-method policy (`false` ignores it entirely). Each method From 77695519071ce3d8cfd332c078cd8c6a767d2b0d Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:16:41 +0200 Subject: [PATCH 12/16] feat(bridgetracker): report per-network scan failures as warnings in activity endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously ActivitySource.BridgesFrom aborted the whole GET /activity/from/{address} scan on the first network it could not reach, even though every other network's bridges were still available. Now it skips the failing network, logs it, and the endpoint reports it in a new "warnings" field instead — bridges found on every other network are still returned. - domain: new ActivityWarning type; ActivityBridgeScanner.BridgesFrom and ActivityQuerier.GetActivity now also return []ActivityWarning - sources: ActivitySource.BridgesFrom continues past a failing network instead of returning early; gained a logger - api: ActivityResponse gains an omitempty "warnings" field ([]ActivityWarningItem) - docs/swagger regenerated for the new field --- bridgetracker/activity.go | 12 ++-- bridgetracker/activity_test.go | 53 ++++++++-------- bridgetracker/api/activity_command.go | 43 +++++++++++-- bridgetracker/api/docs/docs.go | 50 +++++++-------- bridgetracker/api/docs/swagger.json | 50 +++++++-------- bridgetracker/api/docs/swagger.yaml | 49 ++++++--------- bridgetracker/bridgetracker_test.go | 30 +++++++++ bridgetracker/domain/activity.go | 26 ++++++-- .../mocks/mock_activity_bridge_scanner.go | 56 ++++++++++------- .../mocks/mock_activity_claim_checker.go | 33 +++++----- bridgetracker/mocks/mock_activity_querier.go | 50 +++++++++------ bridgetracker/sources/activity.go | 30 ++++++--- bridgetracker/sources/activity_test.go | 62 ++++++++++++++++--- .../swagger/bridge_tracker/swagger.json | 50 +++++++-------- docs/bridgetracker/API.md | 9 +++ proxy/cmd/run.go | 3 +- 16 files changed, 378 insertions(+), 228 deletions(-) diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go index bfa8695f9..e6a713de8 100644 --- a/bridgetracker/activity.go +++ b/bridgetracker/activity.go @@ -80,10 +80,12 @@ func NewActivityCache( // fromAddress that is not yet settled (see settled — their raw bridge data is already cached, so // this needs no bridge-service call), then scans for bridges not seen before (see // ActivityBridgeScanner.BridgesFrom), and returns everything cached for fromAddress that matches -// filter +// filter. The returned []domain.ActivityWarning is whatever the scan reported for networks it +// could not reach this call (see ActivityBridgeScanner.BridgesFrom) — it never fails the call by +// itself, since the result is still valid for every other network. func (a *ActivityCache) GetActivity( ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, -) ([]*domain.ActivityEntry, error) { +) ([]*domain.ActivityEntry, []domain.ActivityWarning, error) { addrCache := a.addrCache(fromAddress) a.mu.Lock() @@ -100,9 +102,9 @@ func (a *ActivityCache) GetActivity( a.upsert(ctx, addrCache, scanned, includeTracking, filter) } - newItems, err := a.scanner.BridgesFrom(ctx, fromAddress, known) + newItems, warnings, err := a.scanner.BridgesFrom(ctx, fromAddress, known) if err != nil { - return nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) + return nil, nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) } for _, item := range newItems { a.upsert(ctx, addrCache, item, includeTracking, filter) @@ -116,7 +118,7 @@ func (a *ActivityCache) GetActivity( out = append(out, entry) } } - return out, nil + return out, warnings, nil } // upsert (re)computes item's entry via refresh and stores it, unless it is already cached and diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go index 7fa4754ed..fd45ac345 100644 --- a/bridgetracker/activity_test.go +++ b/bridgetracker/activity_test.go @@ -48,17 +48,18 @@ func scannedBridge(bridge *bridgeservicetypes.BridgeResponse, networkID uint32) type fakeActivityScanner struct { bridges []*domain.ScannedBridge err error + warnings []domain.ActivityWarning calls int lastKnown map[string]struct{} } func (f *fakeActivityScanner) BridgesFrom( _ context.Context, _ common.Address, known map[string]struct{}, -) ([]*domain.ScannedBridge, error) { +) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { f.calls++ f.lastKnown = known if f.err != nil { - return nil, f.err + return nil, nil, f.err } out := make([]*domain.ScannedBridge, 0, len(f.bridges)) for _, b := range f.bridges { @@ -67,7 +68,7 @@ func (f *fakeActivityScanner) BridgesFrom( } out = append(out, b) } - return out, nil + return out, f.warnings, nil } // fakeActivityClaims is a hand-rolled ActivityClaimChecker for tests: isClaimed/claimInfo are @@ -120,7 +121,7 @@ func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { cache := newTestActivityCache(scanner, claims) for range 2 { - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) @@ -141,7 +142,7 @@ func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) @@ -161,12 +162,12 @@ func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Equal(t, claim, entries[0].Claim) - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, claim, entries[0].Claim) require.Equal(t, 1, claims.isClaimedCalls) @@ -190,12 +191,12 @@ func TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Nil(t, entries[0].Claim) - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) require.Equal(t, claim, entries[0].Claim) @@ -210,7 +211,7 @@ func TestActivityCache_ScannerErrorFailsTheCall(t *testing.T) { scanner := &fakeActivityScanner{err: wantErr} cache := newTestActivityCache(scanner, &fakeActivityClaims{}) - _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + _, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.ErrorIs(t, err, wantErr) } @@ -227,14 +228,14 @@ func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) require.Nil(t, entries[0].Claim) require.Equal(t, "no bridge contract address configured for network 2", entries[0].Errors["claim"]) // the error state is not settled: it is retried on the next call - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) require.Equal(t, 2, claims.isClaimedCalls) @@ -257,7 +258,7 @@ func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, pendingBridge.Bridge, entries[0].Bridge) @@ -281,7 +282,7 @@ func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterError) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterError) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, erroredBridge.Bridge, entries[0].Bridge) @@ -307,7 +308,7 @@ func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterClaimed) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterClaimed) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, claimedBridge.Bridge, entries[0].Bridge) @@ -329,14 +330,14 @@ func TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed(t *tes // filterBridges=pending: claimed, but its claim record is deliberately not fetched, and the // bridge itself is excluded from this result - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) require.NoError(t, err) require.Empty(t, entries) require.Equal(t, 0, claims.claimInfoCalls) // filterBridges=all: the still-unsettled entry is rechecked — its claim record is fetched, // but isClaimed() is not asked again - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, claim, entries[0].Claim) @@ -354,11 +355,11 @@ func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { cache := newTestActivityCache(scanner, claims) - _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + _, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Empty(t, scanner.lastKnown, "nothing cached yet on the first call") - _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + _, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Contains(t, scanner.lastKnown, bridge.Bridge.GlobalIndex.String()) } @@ -380,14 +381,14 @@ func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { now := time.Now() cache.now = func() time.Time { return now } - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, claim, entries[0].Claim) require.Equal(t, 1, claims.isClaimedCalls, "settled after the first call") now = now.Add(2 * time.Minute) // past idleTimeout - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.Equal(t, claim, entries[0].Claim) require.Equal(t, 2, claims.isClaimedCalls, "the address was forgotten, so isClaimed is asked again from scratch") @@ -403,7 +404,7 @@ func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { t1 := time.Now() cache.now = func() time.Time { return t1 } - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.True(t, entries[0].CreatedAt.Equal(t1)) require.True(t, entries[0].UpdatedAt.Equal(t1)) @@ -411,7 +412,7 @@ func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { t2 := t1.Add(time.Minute) cache.now = func() time.Time { return t2 } - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.True(t, entries[0].CreatedAt.Equal(t1), "creation time must not change") require.True(t, entries[0].UpdatedAt.Equal(t2), "update time must advance on every recheck") @@ -429,13 +430,13 @@ func TestActivityCache_TimestampsFreezeOnceSettled(t *testing.T) { t1 := time.Now() cache.now = func() time.Time { return t1 } - entries, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.True(t, entries[0].UpdatedAt.Equal(t1)) cache.now = func() time.Time { return t1.Add(time.Minute) } - entries, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) require.NoError(t, err) require.True(t, entries[0].UpdatedAt.Equal(t1), "a settled entry is never refreshed again") } @@ -457,7 +458,7 @@ func TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork(t cache := newTestActivityCache(scanner, claims) - entries, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) require.NoError(t, err) require.Len(t, entries, 1) require.Equal(t, scannedNetworkID, entries[0].BridgeNetworkID) diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go index cc6950a2c..c89287fb8 100644 --- a/bridgetracker/api/activity_command.go +++ b/bridgetracker/api/activity_command.go @@ -58,12 +58,26 @@ type ActivityItem struct { Errors map[string]string `json:"errors,omitempty"` } +// ActivityWarningItem reports one network's bridge service that could not be scanned while +// building this response — Bridges is still whatever every other network reported, just +// possibly incomplete for the networks listed here +type ActivityWarningItem struct { + // NetworkID is the network whose bridge service could not be scanned + NetworkID uint32 `json:"network_id"` + // Message is the error encountered while scanning NetworkID + Message string `json:"message"` +} + // ActivityResponse is the body of GET /activity/from/{from_address} type ActivityResponse struct { // FromAddress is the address requested FromAddress common.Address `json:"from_address"` // Bridges holds every bridge found for FromAddress across every configured bridge service Bridges []ActivityItem `json:"bridges"` + // Warnings lists every network whose bridge service could not be scanned this call; absent + // when every configured network was scanned successfully. Bridges may be incomplete for the + // networks listed here, but is still valid for every other network + Warnings []ActivityWarningItem `json:"warnings,omitempty"` } // Execute implements command: it scans every configured bridge service for bridges sent by the @@ -72,7 +86,9 @@ type ActivityResponse struct { // bridge tracker (same effect as calling GetTxStatus for it) and includes its current tracking // snapshot. ?filterBridges=claimed|pending|error restricts the result to only bridges with that // claim state (default "all"); a claimed bridge excluded by "pending"/"error" never has its -// claim record fetched, so switching back to "all"/"claimed" later fetches it then. +// claim record fetched, so switching back to "all"/"claimed" later fetches it then. A network +// whose bridge service could not be scanned never fails the request: it is skipped and reported +// in the "warnings" field instead, so Bridges is still whatever every other network reported. // 200 OK unless: invalid from_address/filterBridges (ErrorData/400), or the scan itself failed // (ErrorData/500) // @@ -84,7 +100,9 @@ type ActivityResponse struct { // @Description includeTracking=true additionally registers every still-unclaimed bridge with // @Description the bridge tracker and includes its current tracking snapshot. filterBridges // @Description restricts the result to bridges with only that claim state (claimed / still -// @Description pending / errored while checking). +// @Description pending / errored while checking). A network whose bridge service could not be +// @Description scanned is skipped and reported in the "warnings" field instead of failing the +// @Description whole request. // @Tags bridge-tracker // @Produce json // @Param from_address path string true "Address that sent the bridges to look up" @@ -107,12 +125,16 @@ func (cmd *activityCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: err.Error()} } - entries, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking, filter) + entries, warnings, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking, filter) if err != nil { return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} } - return http.StatusOK, ActivityResponse{FromAddress: fromAddress, Bridges: newActivityItems(entries)}, nil + return http.StatusOK, ActivityResponse{ + FromAddress: fromAddress, + Bridges: newActivityItems(entries), + Warnings: newActivityWarningItems(warnings), + }, nil } // newActivityItems builds the wire ActivityItems from the resolved activity entries @@ -139,3 +161,16 @@ func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { } return items } + +// newActivityWarningItems builds the wire ActivityWarningItems from the scan's warnings; nil in +// (every network scanned fine) yields nil out, so Warnings is omitted from the response entirely +func newActivityWarningItems(warnings []domain.ActivityWarning) []ActivityWarningItem { + if len(warnings) == 0 { + return nil + } + items := make([]ActivityWarningItem, 0, len(warnings)) + for _, w := range warnings { + items = append(items, ActivityWarningItem{NetworkID: w.NetworkID, Message: w.Message}) + } + return items +} diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index 5d18a568b..aeaa20fed 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -24,7 +24,7 @@ const docTemplate = `{ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", "produces": [ "application/json" ], @@ -331,6 +331,26 @@ const docTemplate = `{ "items": { "type": "integer" } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" } } }, @@ -537,20 +557,6 @@ const docTemplate = `{ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -575,20 +581,6 @@ const docTemplate = `{ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 85b8399b8..015001b08 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -17,7 +17,7 @@ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", "produces": [ "application/json" ], @@ -324,6 +324,26 @@ "items": { "type": "integer" } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" } } }, @@ -530,20 +550,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -568,20 +574,6 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index aa51a7757..01649eb95 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -70,6 +70,23 @@ definitions: items: type: integer type: array + warnings: + description: |- + Warnings lists every network whose bridge service could not be scanned this call; absent + when every configured network was scanned successfully. Bridges may be incomplete for the + networks listed here, but is still valid for every other network + items: + $ref: '#/definitions/api.ActivityWarningItem' + type: array + type: object + api.ActivityWarningItem: + properties: + message: + description: Message is the error encountered while scanning NetworkID + type: string + network_id: + description: NetworkID is the network whose bridge service could not be scanned + type: integer type: object api.BridgeAddressItem: properties: @@ -251,20 +268,6 @@ definitions: - 1000000000 - 60000000000 - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - - 1 - - 1000 - - 1000000 - - 1000000000 - - 60000000000 - - 3600000000000 - - 1 - - 1000 - - 1000000 - - 1000000000 - - 60000000000 - - 3600000000000 - 1 - 1000 - 1000000 @@ -290,20 +293,6 @@ definitions: - Second - Minute - Hour - - minDuration - - maxDuration - - Nanosecond - - Microsecond - - Millisecond - - Second - - Minute - - Hour - - Nanosecond - - Microsecond - - Millisecond - - Second - - Minute - - Hour - Nanosecond - Microsecond - Millisecond @@ -575,7 +564,9 @@ paths: includeTracking=true additionally registers every still-unclaimed bridge with the bridge tracker and includes its current tracking snapshot. filterBridges restricts the result to bridges with only that claim state (claimed / still - pending / errored while checking). + pending / errored while checking). A network whose bridge service could not be + scanned is skipped and reported in the "warnings" field instead of failing the + whole request. parameters: - description: Address that sent the bridges to look up in: path diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 7b845d526..e5275a2db 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -395,6 +395,36 @@ func TestActivityHandlerHappyPath(t *testing.T) { require.NotZero(t, body.Bridges[0].LastUpdatedTimestamp) } +// TestActivityHandlerScannerWarningsSurfaceInResponse verifies a network the scanner could not +// reach never fails the request: the bridges found on every other network are still returned, +// and the unreachable network is reported in the ActivityResponse's "warnings" field. +func TestActivityHandlerScannerWarningsSurfaceInResponse(t *testing.T) { + bridge := testBridge(1) + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, + warnings: []domain.ActivityWarning{{NetworkID: 7, Message: "bridge service unreachable"}}, + }, + ActivityClaims: &fakeActivityClaims{isClaimed: []bool{false}}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Len(t, body.Warnings, 1) + require.Equal(t, uint32(7), body.Warnings[0].NetworkID) + require.Equal(t, "bridge service unreachable", body.Warnings[0].Message) +} + // TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage verifies a failed isClaimed() // check surfaces as claimed="error" plus the failure message under errors["claim"], instead of // being silently reported as unclaimed. diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go index 1d19d5651..e55390351 100644 --- a/bridgetracker/domain/activity.go +++ b/bridgetracker/domain/activity.go @@ -58,6 +58,17 @@ type ActivityEntry struct { UpdatedAt time.Time } +// ActivityWarning reports one network's bridge service that could not be scanned/reached while +// serving a request that spans every configured network (see ActivityBridgeScanner.BridgesFrom, +// ActivityQuerier.GetActivity) — the scan still succeeds with whatever every other network +// reported, but the caller must be told this network's activity may be incomplete +type ActivityWarning struct { + // NetworkID is the network whose bridge service could not be scanned + NetworkID uint32 + // Message is the error encountered while scanning NetworkID + Message string +} + // ActivityBridgeScanner is the driven port to the raw bridge-service data behind the // GET /activity/from/{from_address} endpoint: it scans every bridge service the tracker knows // about for bridges sent by fromAddress @@ -68,10 +79,14 @@ type ActivityBridgeScanner interface { // GlobalIndex is unique across the whole system); implementations may use it to stop // scanning a network as soon as an already-known bridge is reached, since each network's // own bridge service reports bridges newest-first and is append-only, so anything after the - // first known bridge is guaranteed already known too (see sources.ActivitySource) + // first known bridge is guaranteed already known too (see sources.ActivitySource). + // + // A network whose bridge service cannot be scanned does not fail the call: it is skipped and + // reported back as an ActivityWarning instead, so one misbehaving network never hides every + // other network's activity. BridgesFrom( ctx context.Context, fromAddress common.Address, known map[string]struct{}, - ) ([]*ScannedBridge, error) + ) ([]*ScannedBridge, []ActivityWarning, error) } // ActivityClaimChecker is the driven port to a bridge's claim state on its destination @@ -91,8 +106,11 @@ type ActivityQuerier interface { // GetActivity returns the bridges sent by fromAddress across every configured bridge // service, enriched with their claim state and filtered per filter (see // types.ActivityFilter); includeTracking additionally feeds every still-unclaimed bridge in - // the result to the bridge tracker (see ActivityEntry.Tracking) + // the result to the bridge tracker (see ActivityEntry.Tracking). The returned + // []ActivityWarning lists every network whose bridge service could not be scanned this call + // (see ActivityBridgeScanner.BridgesFrom) — the result is still whatever every other network + // reported, just possibly incomplete for the networks listed GetActivity( ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, - ) ([]*ActivityEntry, error) + ) ([]*ActivityEntry, []ActivityWarning, error) } diff --git a/bridgetracker/mocks/mock_activity_bridge_scanner.go b/bridgetracker/mocks/mock_activity_bridge_scanner.go index 6c6612208..bef53b739 100644 --- a/bridgetracker/mocks/mock_activity_bridge_scanner.go +++ b/bridgetracker/mocks/mock_activity_bridge_scanner.go @@ -7,9 +7,9 @@ import ( common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" + domain "github.com/agglayer/aggkit/bridgetracker/domain" - types "github.com/agglayer/aggkit/bridgeservice/types" + mock "github.com/stretchr/testify/mock" ) // ActivityBridgeScanner is an autogenerated mock type for the ActivityBridgeScanner type @@ -25,34 +25,43 @@ func (_m *ActivityBridgeScanner) EXPECT() *ActivityBridgeScanner_Expecter { return &ActivityBridgeScanner_Expecter{mock: &_m.Mock} } -// BridgesFrom provides a mock function with given fields: ctx, fromAddress -func (_m *ActivityBridgeScanner) BridgesFrom(ctx context.Context, fromAddress common.Address) ([]*types.BridgeResponse, error) { - ret := _m.Called(ctx, fromAddress) +// BridgesFrom provides a mock function with given fields: ctx, fromAddress, known +func (_m *ActivityBridgeScanner) BridgesFrom(ctx context.Context, fromAddress common.Address, known map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { + ret := _m.Called(ctx, fromAddress, known) if len(ret) == 0 { panic("no return value specified for BridgesFrom") } - var r0 []*types.BridgeResponse - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, common.Address) ([]*types.BridgeResponse, error)); ok { - return rf(ctx, fromAddress) + var r0 []*domain.ScannedBridge + var r1 []domain.ActivityWarning + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error)); ok { + return rf(ctx, fromAddress, known) } - if rf, ok := ret.Get(0).(func(context.Context, common.Address) []*types.BridgeResponse); ok { - r0 = rf(ctx, fromAddress) + if rf, ok := ret.Get(0).(func(context.Context, common.Address, map[string]struct{}) []*domain.ScannedBridge); ok { + r0 = rf(ctx, fromAddress, known) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*types.BridgeResponse) + r0 = ret.Get(0).([]*domain.ScannedBridge) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, map[string]struct{}) []domain.ActivityWarning); ok { + r1 = rf(ctx, fromAddress, known) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.ActivityWarning) } } - if rf, ok := ret.Get(1).(func(context.Context, common.Address) error); ok { - r1 = rf(ctx, fromAddress) + if rf, ok := ret.Get(2).(func(context.Context, common.Address, map[string]struct{}) error); ok { + r2 = rf(ctx, fromAddress, known) } else { - r1 = ret.Error(1) + r2 = ret.Error(2) } - return r0, r1 + return r0, r1, r2 } // ActivityBridgeScanner_BridgesFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BridgesFrom' @@ -63,23 +72,24 @@ type ActivityBridgeScanner_BridgesFrom_Call struct { // BridgesFrom is a helper method to define mock.On call // - ctx context.Context // - fromAddress common.Address -func (_e *ActivityBridgeScanner_Expecter) BridgesFrom(ctx interface{}, fromAddress interface{}) *ActivityBridgeScanner_BridgesFrom_Call { - return &ActivityBridgeScanner_BridgesFrom_Call{Call: _e.mock.On("BridgesFrom", ctx, fromAddress)} +// - known map[string]struct{} +func (_e *ActivityBridgeScanner_Expecter) BridgesFrom(ctx interface{}, fromAddress interface{}, known interface{}) *ActivityBridgeScanner_BridgesFrom_Call { + return &ActivityBridgeScanner_BridgesFrom_Call{Call: _e.mock.On("BridgesFrom", ctx, fromAddress, known)} } -func (_c *ActivityBridgeScanner_BridgesFrom_Call) Run(run func(ctx context.Context, fromAddress common.Address)) *ActivityBridgeScanner_BridgesFrom_Call { +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Run(run func(ctx context.Context, fromAddress common.Address, known map[string]struct{})) *ActivityBridgeScanner_BridgesFrom_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(common.Address)) + run(args[0].(context.Context), args[1].(common.Address), args[2].(map[string]struct{})) }) return _c } -func (_c *ActivityBridgeScanner_BridgesFrom_Call) Return(_a0 []*types.BridgeResponse, _a1 error) *ActivityBridgeScanner_BridgesFrom_Call { - _c.Call.Return(_a0, _a1) +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Return(_a0 []*domain.ScannedBridge, _a1 []domain.ActivityWarning, _a2 error) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Return(_a0, _a1, _a2) return _c } -func (_c *ActivityBridgeScanner_BridgesFrom_Call) RunAndReturn(run func(context.Context, common.Address) ([]*types.BridgeResponse, error)) *ActivityBridgeScanner_BridgesFrom_Call { +func (_c *ActivityBridgeScanner_BridgesFrom_Call) RunAndReturn(run func(context.Context, common.Address, map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error)) *ActivityBridgeScanner_BridgesFrom_Call { _c.Call.Return(run) return _c } diff --git a/bridgetracker/mocks/mock_activity_claim_checker.go b/bridgetracker/mocks/mock_activity_claim_checker.go index 0bd4ae170..4026160a3 100644 --- a/bridgetracker/mocks/mock_activity_claim_checker.go +++ b/bridgetracker/mocks/mock_activity_claim_checker.go @@ -5,6 +5,7 @@ package mocks import ( context "context" + domain "github.com/agglayer/aggkit/bridgetracker/domain" mock "github.com/stretchr/testify/mock" types "github.com/agglayer/aggkit/bridgeservice/types" @@ -24,7 +25,7 @@ func (_m *ActivityClaimChecker) EXPECT() *ActivityClaimChecker_Expecter { } // ClaimInfo provides a mock function with given fields: ctx, bridge -func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *types.BridgeResponse) (*types.ClaimResponse, error) { +func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *domain.ScannedBridge) (*types.ClaimResponse, error) { ret := _m.Called(ctx, bridge) if len(ret) == 0 { @@ -33,10 +34,10 @@ func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *types.Bri var r0 *types.ClaimResponse var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) (*types.ClaimResponse, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) (*types.ClaimResponse, error)); ok { return rf(ctx, bridge) } - if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) *types.ClaimResponse); ok { + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) *types.ClaimResponse); ok { r0 = rf(ctx, bridge) } else { if ret.Get(0) != nil { @@ -44,7 +45,7 @@ func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *types.Bri } } - if rf, ok := ret.Get(1).(func(context.Context, *types.BridgeResponse) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *domain.ScannedBridge) error); ok { r1 = rf(ctx, bridge) } else { r1 = ret.Error(1) @@ -60,14 +61,14 @@ type ActivityClaimChecker_ClaimInfo_Call struct { // ClaimInfo is a helper method to define mock.On call // - ctx context.Context -// - bridge *types.BridgeResponse +// - bridge *domain.ScannedBridge func (_e *ActivityClaimChecker_Expecter) ClaimInfo(ctx interface{}, bridge interface{}) *ActivityClaimChecker_ClaimInfo_Call { return &ActivityClaimChecker_ClaimInfo_Call{Call: _e.mock.On("ClaimInfo", ctx, bridge)} } -func (_c *ActivityClaimChecker_ClaimInfo_Call) Run(run func(ctx context.Context, bridge *types.BridgeResponse)) *ActivityClaimChecker_ClaimInfo_Call { +func (_c *ActivityClaimChecker_ClaimInfo_Call) Run(run func(ctx context.Context, bridge *domain.ScannedBridge)) *ActivityClaimChecker_ClaimInfo_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(*types.BridgeResponse)) + run(args[0].(context.Context), args[1].(*domain.ScannedBridge)) }) return _c } @@ -77,13 +78,13 @@ func (_c *ActivityClaimChecker_ClaimInfo_Call) Return(_a0 *types.ClaimResponse, return _c } -func (_c *ActivityClaimChecker_ClaimInfo_Call) RunAndReturn(run func(context.Context, *types.BridgeResponse) (*types.ClaimResponse, error)) *ActivityClaimChecker_ClaimInfo_Call { +func (_c *ActivityClaimChecker_ClaimInfo_Call) RunAndReturn(run func(context.Context, *domain.ScannedBridge) (*types.ClaimResponse, error)) *ActivityClaimChecker_ClaimInfo_Call { _c.Call.Return(run) return _c } // IsClaimed provides a mock function with given fields: ctx, bridge -func (_m *ActivityClaimChecker) IsClaimed(ctx context.Context, bridge *types.BridgeResponse) (bool, error) { +func (_m *ActivityClaimChecker) IsClaimed(ctx context.Context, bridge *domain.ScannedBridge) (bool, error) { ret := _m.Called(ctx, bridge) if len(ret) == 0 { @@ -92,16 +93,16 @@ func (_m *ActivityClaimChecker) IsClaimed(ctx context.Context, bridge *types.Bri var r0 bool var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) (bool, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) (bool, error)); ok { return rf(ctx, bridge) } - if rf, ok := ret.Get(0).(func(context.Context, *types.BridgeResponse) bool); ok { + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) bool); ok { r0 = rf(ctx, bridge) } else { r0 = ret.Get(0).(bool) } - if rf, ok := ret.Get(1).(func(context.Context, *types.BridgeResponse) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *domain.ScannedBridge) error); ok { r1 = rf(ctx, bridge) } else { r1 = ret.Error(1) @@ -117,14 +118,14 @@ type ActivityClaimChecker_IsClaimed_Call struct { // IsClaimed is a helper method to define mock.On call // - ctx context.Context -// - bridge *types.BridgeResponse +// - bridge *domain.ScannedBridge func (_e *ActivityClaimChecker_Expecter) IsClaimed(ctx interface{}, bridge interface{}) *ActivityClaimChecker_IsClaimed_Call { return &ActivityClaimChecker_IsClaimed_Call{Call: _e.mock.On("IsClaimed", ctx, bridge)} } -func (_c *ActivityClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *types.BridgeResponse)) *ActivityClaimChecker_IsClaimed_Call { +func (_c *ActivityClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *domain.ScannedBridge)) *ActivityClaimChecker_IsClaimed_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(*types.BridgeResponse)) + run(args[0].(context.Context), args[1].(*domain.ScannedBridge)) }) return _c } @@ -134,7 +135,7 @@ func (_c *ActivityClaimChecker_IsClaimed_Call) Return(_a0 bool, _a1 error) *Acti return _c } -func (_c *ActivityClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *types.BridgeResponse) (bool, error)) *ActivityClaimChecker_IsClaimed_Call { +func (_c *ActivityClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *domain.ScannedBridge) (bool, error)) *ActivityClaimChecker_IsClaimed_Call { _c.Call.Return(run) return _c } diff --git a/bridgetracker/mocks/mock_activity_querier.go b/bridgetracker/mocks/mock_activity_querier.go index 018b9afdf..07fd4eb09 100644 --- a/bridgetracker/mocks/mock_activity_querier.go +++ b/bridgetracker/mocks/mock_activity_querier.go @@ -10,6 +10,8 @@ import ( domain "github.com/agglayer/aggkit/bridgetracker/domain" mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/bridgetracker/types" ) // ActivityQuerier is an autogenerated mock type for the ActivityQuerier type @@ -25,34 +27,43 @@ func (_m *ActivityQuerier) EXPECT() *ActivityQuerier_Expecter { return &ActivityQuerier_Expecter{mock: &_m.Mock} } -// GetActivity provides a mock function with given fields: ctx, fromAddress, includeTracking -func (_m *ActivityQuerier) GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool) ([]*domain.ActivityEntry, error) { - ret := _m.Called(ctx, fromAddress, includeTracking) +// GetActivity provides a mock function with given fields: ctx, fromAddress, includeTracking, filter +func (_m *ActivityQuerier) GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error) { + ret := _m.Called(ctx, fromAddress, includeTracking, filter) if len(ret) == 0 { panic("no return value specified for GetActivity") } var r0 []*domain.ActivityEntry - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool) ([]*domain.ActivityEntry, error)); ok { - return rf(ctx, fromAddress, includeTracking) + var r1 []domain.ActivityWarning + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool, types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error)); ok { + return rf(ctx, fromAddress, includeTracking, filter) } - if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool) []*domain.ActivityEntry); ok { - r0 = rf(ctx, fromAddress, includeTracking) + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool, types.ActivityFilter) []*domain.ActivityEntry); ok { + r0 = rf(ctx, fromAddress, includeTracking, filter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*domain.ActivityEntry) } } - if rf, ok := ret.Get(1).(func(context.Context, common.Address, bool) error); ok { - r1 = rf(ctx, fromAddress, includeTracking) + if rf, ok := ret.Get(1).(func(context.Context, common.Address, bool, types.ActivityFilter) []domain.ActivityWarning); ok { + r1 = rf(ctx, fromAddress, includeTracking, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.ActivityWarning) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, common.Address, bool, types.ActivityFilter) error); ok { + r2 = rf(ctx, fromAddress, includeTracking, filter) } else { - r1 = ret.Error(1) + r2 = ret.Error(2) } - return r0, r1 + return r0, r1, r2 } // ActivityQuerier_GetActivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActivity' @@ -64,23 +75,24 @@ type ActivityQuerier_GetActivity_Call struct { // - ctx context.Context // - fromAddress common.Address // - includeTracking bool -func (_e *ActivityQuerier_Expecter) GetActivity(ctx interface{}, fromAddress interface{}, includeTracking interface{}) *ActivityQuerier_GetActivity_Call { - return &ActivityQuerier_GetActivity_Call{Call: _e.mock.On("GetActivity", ctx, fromAddress, includeTracking)} +// - filter types.ActivityFilter +func (_e *ActivityQuerier_Expecter) GetActivity(ctx interface{}, fromAddress interface{}, includeTracking interface{}, filter interface{}) *ActivityQuerier_GetActivity_Call { + return &ActivityQuerier_GetActivity_Call{Call: _e.mock.On("GetActivity", ctx, fromAddress, includeTracking, filter)} } -func (_c *ActivityQuerier_GetActivity_Call) Run(run func(ctx context.Context, fromAddress common.Address, includeTracking bool)) *ActivityQuerier_GetActivity_Call { +func (_c *ActivityQuerier_GetActivity_Call) Run(run func(ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter)) *ActivityQuerier_GetActivity_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(common.Address), args[2].(bool)) + run(args[0].(context.Context), args[1].(common.Address), args[2].(bool), args[3].(types.ActivityFilter)) }) return _c } -func (_c *ActivityQuerier_GetActivity_Call) Return(_a0 []*domain.ActivityEntry, _a1 error) *ActivityQuerier_GetActivity_Call { - _c.Call.Return(_a0, _a1) +func (_c *ActivityQuerier_GetActivity_Call) Return(_a0 []*domain.ActivityEntry, _a1 []domain.ActivityWarning, _a2 error) *ActivityQuerier_GetActivity_Call { + _c.Call.Return(_a0, _a1, _a2) return _c } -func (_c *ActivityQuerier_GetActivity_Call) RunAndReturn(run func(context.Context, common.Address, bool) ([]*domain.ActivityEntry, error)) *ActivityQuerier_GetActivity_Call { +func (_c *ActivityQuerier_GetActivity_Call) RunAndReturn(run func(context.Context, common.Address, bool, types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error)) *ActivityQuerier_GetActivity_Call { _c.Call.Return(run) return _c } diff --git a/bridgetracker/sources/activity.go b/bridgetracker/sources/activity.go index 23b2b3849..408b79bd0 100644 --- a/bridgetracker/sources/activity.go +++ b/bridgetracker/sources/activity.go @@ -7,6 +7,7 @@ import ( "github.com/agglayer/aggkit/bridgeservice/client" bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/domain" + aggkitcommon "github.com/agglayer/aggkit/common" "github.com/ethereum/go-ethereum/common" ) @@ -34,6 +35,7 @@ type NetworkLister interface { // destination network — isClaimed() on the destination bridge contract as the source of truth, // then the destination bridge service's own claim record once claimed. type ActivitySource struct { + logger aggkitcommon.Logger services *bridgeServiceClients finder NetworkLister // contractClaimCheckers resolves/caches the on-chain isClaimed() binding per destination @@ -46,8 +48,9 @@ type ActivitySource struct { // destination bridge contract addresses through finder/ethClients (see // bridgeservicefinder.Finder.BridgeAddress for how a destination network's contract address is // resolved and overridden) -func NewActivitySource(finder NetworkLister, ethClients EthClientResolver) *ActivitySource { +func NewActivitySource(finder NetworkLister, ethClients EthClientResolver, logger aggkitcommon.Logger) *ActivitySource { return &ActivitySource{ + logger: logger, services: newBridgeServiceClients(finder), finder: finder, contractClaimCheckers: newContractClaimCheckers(finder, ethClients), @@ -58,27 +61,40 @@ func NewActivitySource(finder NetworkLister, ethClients EthClientResolver) *Acti // bridge service GET /bridge/v1/bridges filtered by from_address, paging until either a short // page or an already-known bridge is reached (see fetchNewBridgesFrom — this relies on the // bridge service reporting bridges newest-first). A network that cannot be reached is logged and -// skipped rather than failing the whole scan, so one misbehaving bridge service does not hide -// every other network's activity. +// skipped, reported back as a domain.ActivityWarning, rather than failing the whole scan, so one +// misbehaving bridge service does not hide every other network's activity. func (s *ActivitySource) BridgesFrom( ctx context.Context, fromAddress common.Address, known map[string]struct{}, -) ([]*domain.ScannedBridge, error) { +) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { addr := fromAddress.Hex() var all []*domain.ScannedBridge + var warnings []domain.ActivityWarning for _, networkID := range s.finder.NetworkIDs() { svc, err := s.services.aggkitBridgeClientFor(networkID) if err != nil { - return nil, fmt.Errorf("resolving bridge service client for network %d: %w", networkID, err) + warnings = append(warnings, s.warnf(networkID, + "resolving bridge service client for network %d: %v", networkID, err)) + continue } items, err := fetchNewBridgesFrom(ctx, svc, networkID, addr, activityPageSize, known) if err != nil { - return nil, fmt.Errorf("fetching bridges from %s on network %d: %w", fromAddress, networkID, err) + warnings = append(warnings, s.warnf(networkID, + "fetching bridges from %s on network %d: %v", fromAddress, networkID, err)) + continue } all = append(all, items...) } - return all, nil + return all, warnings, nil +} + +// warnf logs msg (formatted per fmt.Sprintf's rules on format/args) and turns it into the +// domain.ActivityWarning BridgesFrom reports back for networkID +func (s *ActivitySource) warnf(networkID uint32, format string, args ...any) domain.ActivityWarning { + message := fmt.Sprintf(format, args...) + s.logger.Warnf("activity: %s", message) + return domain.ActivityWarning{NetworkID: networkID, Message: message} } // fetchNewBridgesFrom pages through networkID's GET /bridge/v1/bridges filtered by fromAddress, diff --git a/bridgetracker/sources/activity_test.go b/bridgetracker/sources/activity_test.go index ef29a14ec..67c0958bc 100644 --- a/bridgetracker/sources/activity_test.go +++ b/bridgetracker/sources/activity_test.go @@ -3,6 +3,7 @@ package sources import ( "context" "encoding/json" + "errors" "fmt" "math/big" "net/http" @@ -140,10 +141,11 @@ func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{1, 2}, url: url} - source := NewActivitySource(lister, nil) + source := NewActivitySource(lister, nil, testLogger) - items, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) + items, warnings, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) require.NoError(t, err) + require.Empty(t, warnings) require.Len(t, items, 4) globalIndexes := make([]int64, 0, len(items)) @@ -153,6 +155,52 @@ func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) require.ElementsMatch(t, []int64{1, 2, 3, 5}, globalIndexes) } +// TestActivitySource_BridgesFrom_SkipsUnreachableNetworkAndWarns verifies a network whose bridge +// service cannot be resolved is skipped rather than failing the whole scan, and is reported back +// as an ActivityWarning — every other network's bridges are still returned. +func TestActivitySource_BridgesFrom_SkipsUnreachableNetworkAndWarns(t *testing.T) { + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: {bridgeResponse(1, 2, 0, testFromAddress, 1)}, + }, + } + url := svc.start(t) + // networkID 2 resolves to an empty bridge service URL, which aggkitBridgeClientFor rejects + lister := fakeMixedNetworkLister{ + networkIDs: []uint32{1, 2}, + urls: map[uint32]string{1: url}, + } + + source := NewActivitySource(lister, nil, testLogger) + + items, warnings, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, int64(1), items[0].Bridge.GlobalIndex.Int64()) + + require.Len(t, warnings, 1) + require.Equal(t, uint32(2), warnings[0].NetworkID) + require.Contains(t, warnings[0].Message, "network 2") +} + +// fakeMixedNetworkLister is a NetworkLister whose GetURL result varies per network, unlike +// fakeNetworkLister's single fixed URL — used to simulate one network being unreachable while +// others resolve fine. +type fakeMixedNetworkLister struct { + networkIDs []uint32 + urls map[uint32]string +} + +func (f fakeMixedNetworkLister) GetURL(networkID uint32) (bridgeservicefinder.NetworkURLs, error) { + return bridgeservicefinder.NetworkURLs{BridgeURL: f.urls[networkID]}, nil +} + +func (f fakeMixedNetworkLister) NetworkIDs() []uint32 { return f.networkIDs } + +func (f fakeMixedNetworkLister) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, errors.New("not implemented") +} + // TestFetchNewBridgesFrom_Pagination exercises the pagination loop directly with a small page // size, so a short page (fewer results than requested) stops the loop. func TestFetchNewBridgesFrom_Pagination(t *testing.T) { @@ -167,7 +215,7 @@ func TestFetchNewBridgesFrom_Pagination(t *testing.T) { } url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} - source := NewActivitySource(lister, nil) + source := NewActivitySource(lister, nil, testLogger) client, err := source.services.aggkitBridgeClientFor(1) require.NoError(t, err) @@ -193,7 +241,7 @@ func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { } url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} - source := NewActivitySource(lister, nil) + source := NewActivitySource(lister, nil, testLogger) client, err := source.services.aggkitBridgeClientFor(1) require.NoError(t, err) @@ -207,7 +255,7 @@ func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { // TestActivitySource_IsClaimed_NoBridgeAddrConfigured verifies IsClaimed errors clearly when // the destination network has no bridge contract address configured. func TestActivitySource_IsClaimed_NoBridgeAddrConfigured(t *testing.T) { - source := NewActivitySource(fakeNetworkLister{}, StaticClients{}) + source := NewActivitySource(fakeNetworkLister{}, StaticClients{}, testLogger) bridge := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 3, testFromAddress, 1), NetworkID: 1} _, err := source.IsClaimed(t.Context(), bridge) @@ -226,7 +274,7 @@ func TestActivitySource_IsClaimed_UsesScannedNetworkNotBridgeOriginNetwork(t *te stub := &stubClaimChecker{claimed: true} buildCalls := 0 lister := fakeNetworkLister{bridgeAddrs: map[uint32]common.Address{2: destAddr}} - source := NewActivitySource(lister, client) + source := NewActivitySource(lister, client, testLogger) source.newContract = func(addr common.Address, _ aggkittypes.BaseEthereumClienter) (claimChecker, error) { buildCalls++ require.Equal(t, destAddr, addr) @@ -272,7 +320,7 @@ func TestActivitySource_ClaimInfo(t *testing.T) { } url := svc.start(t) lister := fakeNetworkLister{networkIDs: []uint32{2}, url: url} - source := NewActivitySource(lister, nil) + source := NewActivitySource(lister, nil, testLogger) found := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 0, testFromAddress, 1), NetworkID: 1} got, err := source.ClaimInfo(t.Context(), found) diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 85b8399b8..015001b08 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -17,7 +17,7 @@ "paths": { "/activity/from/{from_address}": { "get": { - "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking).", + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", "produces": [ "application/json" ], @@ -324,6 +324,26 @@ "items": { "type": "integer" } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" } } }, @@ -530,20 +550,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -568,20 +574,6 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index e1fe99c99..d1cacfe81 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -362,6 +362,7 @@ Request: - `500 Internal Server Error` — scanning the configured bridge services failed: the body is an [ErrorData](#errordata). - **This endpoint is opt-in**: it only exists if the binary is configured with both an activity bridge scanner and claim checker (`Config.ActivityScanner`/`ActivityClaims`); otherwise the route is not registered at all (plain `404`). - Requesting `filterBridges=pending` or `filterBridges=error` **skips fetching the claim record** of a bridge found to be claimed, since it would be filtered out of that result anyway — its cache entry simply has no `claim` yet, and is fetched normally the next time `filterBridges=all`/`claimed` is used for that address. +- A network whose bridge service could not be scanned **never fails the request**: it is skipped and reported in `warnings` instead, so `bridges` is still whatever every other network reported (possibly incomplete for the networks listed in `warnings`). ### ActivityResponse @@ -369,6 +370,14 @@ Request: | ------|------|------| | from_address | Address | the address requested | | bridges | ActivityItem [] | every bridge found for `from_address`, across every configured bridge service, matching `filterBridges` | +| warnings | ActivityWarningItem [] | every network whose bridge service could not be scanned this call; **omitted** (no key) when every configured network was scanned successfully | + +### ActivityWarningItem + +| field | type | desc | +| ------|------|------| +| network_id | uint32 | the network whose bridge service could not be scanned | +| message | string | the error encountered while scanning `network_id` | ### ActivityItem diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index e696760e6..1af951c0c 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -192,7 +192,8 @@ func runTracker( // finder.NetworkIDs) for bridges sent by an address, and resolves their claim state through // the same per-network JSON-RPC clients plus the finder's own BridgeAddress resolution // (see BridgeServiceFinder.BridgeAddress, distinct from Tracker.BridgeAddrs above) - activitySource := sources.NewActivitySource(finder, rpcClients) + activitySource := sources.NewActivitySource( + finder, rpcClients, log.WithFields("module", "bridgetracker-activitysource")) trackerCfg.ActivityScanner = activitySource trackerCfg.ActivityClaims = activitySource From 23e68c8590ca3cf3a474a4dbacedde9502aeb11a Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:30:36 +0200 Subject: [PATCH 13/16] feat(bridgetracker): resolve actual L2 GER injection block, with fallback and lookback cap InjectedGERResult now splits into L1InfoTreeLeaf (the L1 UpdateL1InfoTree event that produced the leaf) and the new, optional L2InjectedGER (the actual L2 block/timestamp the GER was injected at on the destination network) -- fixing #1818, where the L1 block was returned in the L2 field's place. - bridgeservice.L1InfoTreeLeafResponse carries the new injected_l2_block_num/injected_l2_block_timestamp fields when the destination's bridge-service instance reports them. - When it doesn't (an older instance), GERSource falls back to scanning the destination network's own GlobalExitRootManagerL2 contract for the UpdateHashChainValue event, backwards in chunks, via the new Tracker.L2GlobalExitRootAddress per-network contract address map. - That backward scan is now bounded by the new Tracker.L2InjectionLookbackBlocks (default 1,000 blocks) instead of always walking back to genesis, so a network where the fallback never finds the event doesn't cost an unbounded eth_getLogs scan on every lookup. - Document both new Tracker config fields (docs/bridgetracker.md, proxy/config/default.go) and the new InjectedGERResult response shape (docs/bridgetracker/API.md). Co-Authored-By: Claude Sonnet 5 --- bridgetracker/config.go | 22 ++ .../resolve_step_waiting_ger_injection.go | 14 +- bridgetracker/engine_test.go | 10 +- bridgetracker/sources/ger.go | 161 +++++++++++- bridgetracker/sources/sources_test.go | 230 +++++++++++++++++- bridgetracker/types/status.go | 37 ++- docs/bridgetracker.md | 8 +- docs/bridgetracker/API.md | 64 ++++- proxy/cmd/run.go | 3 +- proxy/config/config_test.go | 1 + proxy/config/default.go | 5 + 11 files changed, 538 insertions(+), 17 deletions(-) diff --git a/bridgetracker/config.go b/bridgetracker/config.go index dc1c999f7..a682663d0 100644 --- a/bridgetracker/config.go +++ b/bridgetracker/config.go @@ -41,6 +41,10 @@ const DefaultMaxTrackedBridges = 100_000 // semantics; both must stay in sync with the [Tracker] section of the proxy's default config) var DefaultIdleTimeout = types.Duration{Duration: DefaultEngineIdleTimeout} +// DefaultL2InjectionLookbackBlocks is the default Config.L2InjectionLookbackBlocks (must stay in +// sync with the [Tracker] section of the proxy's default config) +const DefaultL2InjectionLookbackBlocks = 1_000 + // Config holds the configuration of the bridge tracker service. Only the mapstructure-tagged // fields come from the configuration file; the rest are wired programmatically by the binary // (see proxy/cmd) @@ -87,6 +91,24 @@ type Config struct { // L1GlobalExitRootAddress is the L1 GlobalExitRoot contract address (see sources.GERSource) L1GlobalExitRootAddress common.Address `mapstructure:"L1GlobalExitRootAddress"` + // L2GlobalExitRootAddress is the static networkID -> GlobalExitRootManagerL2 contract address + // map used as a fallback when a destination network's bridge-service instance does not + // report the L2 block a covering GER was actually injected at (see sources.GERSource, + // WaitingGERInjection's InjectedGERResult.L2InjectedGER): for a network present here, the + // tracker scans that network's own L2 for the UpdateHashChainValue event instead of leaving + // L2InjectedGER absent. A network absent from this map (the default, empty map) never gets + // this fallback attempted — no different from before it existed + L2GlobalExitRootAddress map[uint32]common.Address `mapstructure:"L2GlobalExitRootAddress"` + + // L2InjectionLookbackBlocks bounds how many blocks the L2GlobalExitRootAddress fallback (see + // sources.GERSource.findL2InjectionBlockBackwards) scans backwards from the destination + // network's head before giving up, instead of continuing all the way back to genesis: on a + // network whose fallback never finds the event (e.g. a wrong L2GlobalExitRootAddress entry, + // or a GER injected before the contract even existed there), an unbounded scan would walk the + // entire chain history in bridgeservicefinder.DefaultBlockChunkSize-sized eth_getLogs calls + // every time. A value <= 0 falls back to DefaultL2InjectionLookbackBlocks. + L2InjectionLookbackBlocks uint64 `mapstructure:"L2InjectionLookbackBlocks"` + // MaxTrackedBridges bounds how many distinct bridges the in-memory registry (see Registry) // accepts at once; a request that would exceed it fails instead of registering the bridge — // reaching the cap never evicts an existing entry to make room, so RetentionPeriod and diff --git a/bridgetracker/domain/resolve_step_waiting_ger_injection.go b/bridgetracker/domain/resolve_step_waiting_ger_injection.go index 603eaa348..f535e0483 100644 --- a/bridgetracker/domain/resolve_step_waiting_ger_injection.go +++ b/bridgetracker/domain/resolve_step_waiting_ger_injection.go @@ -55,12 +55,20 @@ func (r *WaitingGERInjectionResolver) Resolve( return nil, ErrStepPending } - result := &types.InjectedGERResult{GER: *injected.GER} + result := &types.InjectedGERResult{ + L1InfoTreeLeaf: types.InjectedGERL1Leaf{GER: *injected.GER}, + } if injected.BlockNumber != nil { - result.BlockNumber = *injected.BlockNumber + result.L1InfoTreeLeaf.BlockNumber = *injected.BlockNumber } if injected.BlockTimestamp != nil { - result.BlockTimestamp = *injected.BlockTimestamp + result.L1InfoTreeLeaf.BlockTimestamp = *injected.BlockTimestamp + } + // L2InjectedGER stays nil (see InjectedGERResult's doc comment) when the destination's + // bridge-service instance does not report the actual L2 injection block yet + if injected.L2BlockNumber != nil { + result.L2InjectedGER = &types.InjectedL2GERBlock{BlockNumber: *injected.L2BlockNumber} + result.L2InjectedGER.BlockTimestamp = injected.L2BlockTimestamp } return result, nil } diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 85b5d1015..8d951b047 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -573,9 +573,12 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { injectedGER := common.HexToHash("0x04") injectedGERBlockNumber := uint64(200) injectedGERTimestamp := uint64(1700000000) + injectedGERL2BlockNumber := uint64(300) + injectedGERL2Timestamp := uint64(1700000300) f.injectedGERAtIndex = &types.GERData{ NetworkID: 2, GER: &injectedGER, LERType: types.LERTypeLocal, BlockNumber: &injectedGERBlockNumber, BlockTimestamp: &injectedGERTimestamp, + L2BlockNumber: &injectedGERL2BlockNumber, L2BlockTimestamp: &injectedGERL2Timestamp, } engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) @@ -584,7 +587,12 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { for _, sp := range allSteps { if sp.Step == types.StepWaitingGERInjection { require.Equal(t, &types.InjectedGERResult{ - GER: injectedGER, BlockNumber: injectedGERBlockNumber, BlockTimestamp: injectedGERTimestamp, + L1InfoTreeLeaf: types.InjectedGERL1Leaf{ + GER: injectedGER, BlockNumber: injectedGERBlockNumber, BlockTimestamp: injectedGERTimestamp, + }, + L2InjectedGER: &types.InjectedL2GERBlock{ + BlockNumber: injectedGERL2BlockNumber, BlockTimestamp: &injectedGERL2Timestamp, + }, }, sp.Result()) } } diff --git a/bridgetracker/sources/ger.go b/bridgetracker/sources/ger.go index 3e7fb951b..a8d5a6642 100644 --- a/bridgetracker/sources/ger.go +++ b/bridgetracker/sources/ger.go @@ -7,12 +7,15 @@ import ( "math/big" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerger" + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayergerl2" + "github.com/agglayer/aggkit/bridgeservicefinder" "github.com/agglayer/aggkit/bridgetracker" "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -20,6 +23,13 @@ import ( const MainnetNetworkID = 0 +// updateHashChainValueSignature is the topic0 of AgglayerGERL2's UpdateHashChainValue event — +// event UpdateHashChainValue(bytes32 indexed newGlobalExitRoot, bytes32 indexed +// newHashChainValue) — the event findL2InjectionBlockBackwards scans for. Not referenced by the +// filtering itself (l2GERManager.FilterUpdateHashChainValue's ABI binding computes it), only +// documented here for the same reason l2gersync names it (insertGEREventSignature) +var updateHashChainValueSignature = crypto.Keccak256Hash([]byte("UpdateHashChainValue(bytes32,bytes32)")) + // errNotCoveredYet marks a bridge not covered by any L1 info tree leaf yet, as opposed to a // transient failure (URL resolution, network). It never escapes this package var errNotCoveredYet = errors.New("bridge not covered by any L1 info tree leaf yet") @@ -33,15 +43,33 @@ type GERSource struct { clients EthClientResolver ContractGlobalExitRootAddress common.Address L1BlockFinality aggkittypes.BlockNumberFinality + // l2GERAddrs is the static networkID -> GlobalExitRootManagerL2 contract address map used by + // findL2InjectionBlockBackwards, the fallback that scans a destination network's own L2 for + // the UpdateHashChainValue event when its bridge-service instance does not report the + // injection block itself (see InjectedGERAtIndex). A network absent from this map (the + // default, empty map) simply never gets that fallback attempted — L2InjectedGER just stays + // nil for it, exactly as before this existed + l2GERAddrs map[uint32]common.Address + // l2InjectionLookbackBlocks bounds how many blocks findL2InjectionBlockBackwards scans + // backwards from the destination network's head before giving up, instead of continuing all + // the way back to genesis (see bridgetracker.Config.L2InjectionLookbackBlocks). A value <= 0 + // falls back to bridgetracker.DefaultL2InjectionLookbackBlocks + l2InjectionLookbackBlocks uint64 } // NewGERSource returns a GERSource resolving per-network bridge service clients through finder, // and the L1 (network 0) JSON-RPC client used by FindFirstL1InfoTreeAfterBlock through clients. // contractGlobalExitRootAddress is the L1 GlobalExitRoot contract FindFirstL1InfoTreeAfterBlock -// reads UpdateL1InfoTree/UpdateL1InfoTreeV2 logs and state from; l1Finality caps its search range +// reads UpdateL1InfoTree/UpdateL1InfoTreeV2 logs and state from; l1Finality caps its search range. +// l2GERAddrs is the per-network GlobalExitRootManagerL2 contract address map backing +// findL2InjectionBlockBackwards (see GERSource.l2GERAddrs); nil/empty simply disables that +// fallback for every network. l2InjectionLookbackBlocks bounds how far back that same fallback +// scans (see GERSource.l2InjectionLookbackBlocks); a value <= 0 falls back to +// bridgetracker.DefaultL2InjectionLookbackBlocks func NewGERSource( finder NetworkURLResolver, clients EthClientResolver, contractGlobalExitRootAddress common.Address, - l1Finality aggkittypes.BlockNumberFinality, logger aggkitcommon.Logger, + l1Finality aggkittypes.BlockNumberFinality, l2GERAddrs map[uint32]common.Address, + l2InjectionLookbackBlocks uint64, logger aggkitcommon.Logger, ) *GERSource { return &GERSource{ logger: logger, @@ -49,6 +77,8 @@ func NewGERSource( clients: clients, ContractGlobalExitRootAddress: contractGlobalExitRootAddress, L1BlockFinality: l1Finality, + l2GERAddrs: l2GERAddrs, + l2InjectionLookbackBlocks: l2InjectionLookbackBlocks, } } @@ -274,9 +304,17 @@ func (s *GERSource) InjectedGERAtIndex( ger := common.HexToHash(string(leaf.GlobalExitRoot)) mer := common.HexToHash(string(leaf.MainnetExitRoot)) rer := common.HexToHash(string(leaf.RollupExitRoot)) + + // leaf.BlockNumber/Timestamp are always the L1 event that produced the leaf, even for this L2 + // lookup (see InjectedL1InfoLeafHandler) — they never describe the L2 injection block, and + // must not be presented as if they did (that conflation was #1818). The actual L2 injection + // block/timestamp, when the destination's bridge-service instance reports them, go on the + // separate L2BlockNumber/L2BlockTimestamp fields instead — left nil otherwise, rather than + // falling back to the L1 block, so WaitingGERInjectionResolver can honestly report + // InjectedGERResult.L2InjectedGER as absent instead of a wrong value blockNumber := leaf.BlockNumber timestamp := leaf.Timestamp - return &trackertypes.GERData{ + gerData := &trackertypes.GERData{ NetworkID: bridge.DestinationNetwork, GER: &ger, MER: &mer, @@ -284,7 +322,122 @@ func (s *GERSource) InjectedGERAtIndex( LERType: trackertypes.LERTypeNA, BlockNumber: &blockNumber, BlockTimestamp: ×tamp, - }, nil + } + if leaf.InjectedL2BlockNumber != nil { + l2BlockNumber := *leaf.InjectedL2BlockNumber + gerData.L2BlockNumber = &l2BlockNumber + if leaf.InjectedL2BlockTimestamp != nil { + l2Timestamp := *leaf.InjectedL2BlockTimestamp + gerData.L2BlockTimestamp = &l2Timestamp + } + return gerData, nil + } + + // The destination's bridge-service instance predates injected_l2_block_num (see #1818): fall + // back to finding the injection ourselves, straight off the destination network's own L2, if + // its GlobalExitRootManagerL2 address is configured (l2GERAddrs). Best effort — any failure + // here just leaves L2BlockNumber/L2BlockTimestamp nil, same as when the fallback isn't + // configured at all; it must never fail InjectedGERAtIndex itself over this + l2BlockNumber, l2Timestamp, err := s.findL2InjectionBlockBackwards(ctx, bridge.DestinationNetwork, ger) + if err != nil { + s.logger.Warnf("finding L2 injection block for GER %s on network %d: %v", + ger.Hex(), bridge.DestinationNetwork, err) + return gerData, nil + } + gerData.L2BlockNumber = l2BlockNumber + gerData.L2BlockTimestamp = l2Timestamp + return gerData, nil +} + +// findL2InjectionBlockBackwards scans backward, in fixed-size chunks (mirroring +// SettlementSource.findEventUpdateL1InfoTreeBackwards), for the UpdateHashChainValue event that +// injected ger into the GlobalExitRootManagerL2 contract on networkID — used only as a fallback +// when that network's bridge-service instance does not report the injection block itself (see +// InjectedGERAtIndex). Returns (nil, nil, nil) — not an error — when networkID has no configured +// l2GERAddrs entry (the fallback is simply not available for it) or the scan reaches genesis, or +// s.l2InjectionLookbackBlocks (see GERSource.l2InjectionLookbackBlocks), without finding the +// event; the timestamp alone can be (non-nil, nil) if resolving it off the found block's hash +// fails, since the block number is already a genuine, useful answer on its own +func (s *GERSource) findL2InjectionBlockBackwards( + ctx context.Context, networkID uint32, ger common.Hash, +) (blockNumber, timestamp *uint64, err error) { + addr, ok := s.l2GERAddrs[networkID] + if !ok { + return nil, nil, nil // fallback not configured for this network, not an error (see doc comment) + } + client, err := s.clients.RPCClientFor(ctx, networkID) + if err != nil { + return nil, nil, err + } + l2GERManager, err := agglayergerl2.NewAgglayergerl2(addr, client) + if err != nil { + return nil, nil, fmt.Errorf("binding GlobalExitRootManagerL2 contract at %s: %w", addr, err) + } + head, err := client.CustomHeaderByNumber(ctx, &aggkittypes.LatestBlock) + if err != nil { + return nil, nil, fmt.Errorf("fetching latest block of network %d: %w", networkID, err) + } + + lookback := s.l2InjectionLookbackBlocks + if lookback == 0 { + lookback = bridgetracker.DefaultL2InjectionLookbackBlocks + } + // floor is the lowest block the scan will look at: head.Number - lookback, or genesis (0) + // if the network's chain is shorter than that + floor := uint64(0) + if head.Number > lookback { + floor = head.Number - lookback + } + + toBlock := head.Number + for { + fromBlock := floor + if toBlock-floor > bridgeservicefinder.DefaultBlockChunkSize { + fromBlock = toBlock - bridgeservicefinder.DefaultBlockChunkSize + } + + end := toBlock + found, filterErr := s.filterUpdateHashChainValue(ctx, l2GERManager, fromBlock, end, ger) + if filterErr != nil { + return nil, nil, fmt.Errorf("filtering UpdateHashChainValue logs [%d,%d] on network %d: %w", + fromBlock, end, networkID, filterErr) + } + if found != nil { + block := found.BlockNumber + ts, tsErr := blockTimestamp(ctx, client, found.BlockHash) + if tsErr != nil { + return &block, nil, fmt.Errorf("resolving timestamp of block %d: %w", block, tsErr) + } + return &block, &ts, nil + } + if fromBlock == floor { + // scanned back to genesis or the configured lookback limit, the injection event + // genuinely isn't there (within that window) + return nil, nil, nil + } + toBlock = fromBlock - 1 + } +} + +// filterUpdateHashChainValue returns the most recent UpdateHashChainValue log for ger within +// [fromBlock, toBlock] on l2GERManager, or nil if none. The event's first indexed topic is the +// GER itself, so the topic filter alone does the matching — no need to decode every log in range +func (s *GERSource) filterUpdateHashChainValue( + ctx context.Context, l2GERManager *agglayergerl2.Agglayergerl2, fromBlock, toBlock uint64, ger common.Hash, +) (*gethtypes.Log, error) { + iter, err := l2GERManager.FilterUpdateHashChainValue( + &bind.FilterOpts{Start: fromBlock, End: &toBlock, Context: ctx}, [][32]byte{ger}, nil) + if err != nil { + return nil, err + } + defer iter.Close() + + var found *gethtypes.Log + for iter.Next() { + raw := iter.Event.Raw + found = &raw + } + return found, iter.Error() } // coveringLeafIndex resolves the L1 info tree index whose leaf covers the bridge, asking diff --git a/bridgetracker/sources/sources_test.go b/bridgetracker/sources/sources_test.go index 37740d2b1..23cb00bfe 100644 --- a/bridgetracker/sources/sources_test.go +++ b/bridgetracker/sources/sources_test.go @@ -3,6 +3,7 @@ package sources import ( "context" "encoding/json" + "errors" "fmt" "math/big" "net/http" @@ -320,7 +321,7 @@ func TestFinderClients(t *testing.T) { func TestGERSourceOriginGER(t *testing.T) { fake := &fakeBridgeService{} - source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) // not covered yet -> nil, nil ger, err := source.OriginGER(t.Context(), l1ToL2Bridge()) @@ -348,7 +349,8 @@ func TestGERSourceOriginGER(t *testing.T) { func TestGERSourceInjectedGER(t *testing.T) { fake := &fakeBridgeService{} - source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, + log.WithFields("module", "sources_test")) // not even covered on origin -> nil injected, err := source.InjectedGER(t.Context(), l1ToL2Bridge()) @@ -381,6 +383,228 @@ func TestGERSourceInjectedGER(t *testing.T) { require.Equal(t, common.HexToHash("0x0c"), *injected.RER) require.Equal(t, uint64(200), *injected.BlockNumber) require.Equal(t, uint64(1700000000), *injected.BlockTimestamp) + // no L2BlockNumber/L2BlockTimestamp yet: this bridge-service instance predates them, so + // WaitingGERInjectionResolver must not mistake the L1 block above for the L2 injection one + // (that conflation was #1818) + require.Nil(t, injected.L2BlockNumber) + require.Nil(t, injected.L2BlockTimestamp) + + // once the bridge-service reports the real L2 injection block/timestamp, they land on their + // own fields — block_num/timestamp above stay the L1 event's, per InjectedL1InfoLeafHandler + fake.injectedLeaf["injected_l2_block_num"] = 999 + fake.injectedLeaf["injected_l2_block_timestamp"] = 1800000000 + injected, err = source.InjectedGER(t.Context(), l1ToL2Bridge()) + require.NoError(t, err) + require.NotNil(t, injected) + require.Equal(t, uint64(200), *injected.BlockNumber, "the L1 block must stay untouched") + require.Equal(t, uint64(1700000000), *injected.BlockTimestamp, "the L1 timestamp must stay untouched") + require.Equal(t, uint64(999), *injected.L2BlockNumber) + require.Equal(t, uint64(1800000000), *injected.L2BlockTimestamp) +} + +// TestGERSourceInjectedGER_FallsBackToL2Scan covers the #1818 fallback: when the destination's +// bridge-service instance does not report injected_l2_block_num at all, and its +// GlobalExitRootManagerL2 address is configured (l2GERAddrs), InjectedGER finds the injection +// itself by scanning the destination network's own UpdateHashChainValue logs. +func TestGERSourceInjectedGER_FallsBackToL2Scan(t *testing.T) { + fake := &fakeBridgeService{} + idx := uint32(42) + fake.l1InfoTreeIndex = &idx + fake.injectedLeaf = map[string]any{ + "l1_info_tree_index": 42, + "global_exit_root": "0x0a", + "mainnet_exit_root": "0x0b", + "rollup_exit_root": "0x0c", + "block_num": 200, + "timestamp": 1700000000, + // no injected_l2_block_num/injected_l2_block_timestamp: an old bridge-service instance + } + + l2GERAddr := common.HexToAddress("0x1234") + destNetwork := l1ToL2Bridge().DestinationNetwork + + mockL2Client := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(fake.start(t), StaticClients{destNetwork: mockL2Client}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{destNetwork: l2GERAddr}, 0, nil) + + head := uint64(5000) // within a single chunk (DefaultBlockChunkSize=10_000): no backward paging + mockL2Client.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: head}, nil) + + targetGER := common.HexToHash("0x0a") // must match fake.injectedLeaf's global_exit_root + otherGER := common.HexToHash("0x0b") + blockHash := common.HexToHash("0xblockhash") + mockL2Client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + { // an unrelated GER's injection must not be mistaken for the one being searched + Topics: []common.Hash{updateHashChainValueSignature, otherGER, {}}, + BlockNumber: 111, + BlockHash: common.HexToHash("0xother"), + }, + { + Topics: []common.Hash{updateHashChainValueSignature, targetGER, {}}, + BlockNumber: 4321, + BlockHash: blockHash, + }, + }, nil).Once() + mockL2Client.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1800000000}, nil) + + injected, err := source.InjectedGER(t.Context(), l1ToL2Bridge()) + require.NoError(t, err) + require.NotNil(t, injected) + require.Equal(t, uint64(200), *injected.BlockNumber, "the L1 block must stay untouched") + require.NotNil(t, injected.L2BlockNumber) + require.Equal(t, uint64(4321), *injected.L2BlockNumber) + require.NotNil(t, injected.L2BlockTimestamp) + require.Equal(t, uint64(1800000000), *injected.L2BlockTimestamp) +} + +// TestFindL2InjectionBlockBackwards exercises GERSource.findL2InjectionBlockBackwards directly: +// the paginated backward scan, its termination conditions, and how it degrades (never an error +// InjectedGERAtIndex must propagate) when the fallback simply isn't configured for the network. +func TestFindL2InjectionBlockBackwards(t *testing.T) { + ger := common.HexToHash("0x0a") + l2GERAddr := common.HexToAddress("0x1234") + networkID := uint32(1) + + t.Run("network not in l2GERAddrs: no RPC call, nil result", func(t *testing.T) { + source := NewGERSource(nil, nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("found on the very first (most recent) chunk", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 400, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1700000000}, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Equal(t, uint64(400), *blockNumber) + require.Equal(t, uint64(1700000000), *timestamp) + }) + + t.Run("found only after paginating backwards past an empty chunk", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + // an explicit lookback well past head, so the pagination isn't cut short by + // DefaultL2InjectionLookbackBlocks (1_000) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 20_000, nil) + + // head is past one full DefaultBlockChunkSize (10_000), so the first chunk covers + // [5_000, 15_000] (empty) before the second one, [0, 4_999], finds the log + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 15_000}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 123, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1600000000}, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Equal(t, uint64(123), *blockNumber) + require.Equal(t, uint64(1600000000), *timestamp) + }) + + t.Run("never found: scans back to genesis, returns nil without error", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("respects a configured lookback: never scans below the floor", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 5_000, nil) + + // floor = head (15_000) - lookback (5_000) = 10_000: the single chunk [10_000, 15_000] + // already covers the whole allowed window, so exactly one FilterLogs call is made and the + // scan gives up there instead of continuing down towards genesis (a second, unexpected + // FilterLogs call would fail this test: mockClient has no expectation registered for it) + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 15_000}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("lookback <= 0 falls back to DefaultL2InjectionLookbackBlocks: scans back to genesis", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + // head (500) is well within DefaultL2InjectionLookbackBlocks, so the floor is genesis (0) + // exactly as if no lookback had been configured at all + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("head lookup fails: propagates the error", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(nil, errors.New("boom")) + + _, _, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.ErrorContains(t, err, "boom") + }) + + t.Run("found, but resolving the block's timestamp fails: block number still returned", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 400, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash).Return(nil, errors.New("boom")) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.ErrorContains(t, err, "boom") + require.Equal(t, uint64(400), *blockNumber) + require.Nil(t, timestamp) + }) } func TestClaimSourceClaimFor(t *testing.T) { @@ -463,7 +687,7 @@ func TestLERSourceBridgeEventLogNotFound(t *testing.T) { func TestSourcesUnresolvedNetworkIsTransient(t *testing.T) { resolver := staticURLs{} // no networks resolved - gerSource := NewGERSource(resolver, nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + gerSource := NewGERSource(resolver, nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) claimSource := NewClaimSource(resolver) lerSource := NewLERSource(StaticClients{}) diff --git a/bridgetracker/types/status.go b/bridgetracker/types/status.go index eac59cbf3..30d0a40f6 100644 --- a/bridgetracker/types/status.go +++ b/bridgetracker/types/status.go @@ -264,14 +264,37 @@ type GERUpdateResult struct { LogIndex uint `json:"log_index"` } -// InjectedGERResult is the result of StepWaitingGERInjection once it completes: the GER -// injected on the destination network that covers the bridge, and that injection's block +// InjectedGERResult is the result of StepWaitingGERInjection once it completes: the GER covering +// the bridge, resolved to its L1 Info Tree leaf (L1InfoTreeLeaf, always known once the step +// completes) and, once actually injected on the destination network, that injection's own L2 +// block/timestamp (L2InjectedGER). L2InjectedGER is nil when the destination's bridge-service +// instance does not report it yet (predates injected_l2_block_num/injected_l2_block_timestamp on +// GET /bridge/v1/injected-l1-info-leaf, see bridgeservice/types.L1InfoTreeLeafResponse) — in that +// case L1InfoTreeLeaf's own BlockNumber/BlockTimestamp are the L1 event that produced the leaf, +// not the L2 injection block, and must not be mistaken for it (this conflation was #1818) type InjectedGERResult struct { + L1InfoTreeLeaf InjectedGERL1Leaf `json:"l1_info_tree_leaf"` + L2InjectedGER *InjectedL2GERBlock `json:"l2_injected_ger,omitempty"` +} + +// InjectedGERL1Leaf is the L1 Info Tree leaf covering the bridge: its GER and the L1 block/ +// timestamp of the UpdateL1InfoTree/UpdateL1InfoTreeV2 event that produced it +type InjectedGERL1Leaf struct { GER common.Hash `json:"ger"` BlockNumber uint64 `json:"block_number"` BlockTimestamp uint64 `json:"block_timestamp"` } +// InjectedL2GERBlock is the L2 block the GER was actually injected at on the destination +// network. BlockTimestamp is only known once resolved from the destination's L2 RPC (see +// l2gersync.L2GERSync.GetFirstGERAfterL1InfoTreeIndex), so it may briefly be absent right after +// upgrading a bridge-service instance that only just started reporting BlockNumber, resolving on +// a later request +type InjectedL2GERBlock struct { + BlockNumber uint64 `json:"block_number"` + BlockTimestamp *uint64 `json:"block_timestamp,omitempty"` +} + // LERUpdateResult is the result of StepWaitingLERUpdate once it completes: the LER produced // by the update on the origin L2 network and the block it was updated in type LERUpdateResult struct { @@ -345,6 +368,16 @@ type GERData struct { // BlockTimestamp is BlockNumber's block timestamp. Populated (and carried on the wire) under // the same conditions as BlockNumber BlockTimestamp *uint64 `json:"-"` + // L2BlockNumber/L2BlockTimestamp are the actual L2 block/timestamp the GER was injected at on + // the destination network. Only set by InjectedGERAtIndex, and only when the destination's + // bridge-service instance reports it (see bridgeservice/types.L1InfoTreeLeafResponse's + // InjectedL2BlockNumber/InjectedL2BlockTimestamp). Unlike BlockNumber/BlockTimestamp above — + // always the L1 event, even here — these stay nil while unknown instead of being backfilled + // with the L1 block, which is exactly the #1818 bug this pair exists to avoid repeating + L2BlockNumber *uint64 `json:"-"` + // L2BlockTimestamp is L2BlockNumber's timestamp; may lag L2BlockNumber briefly if resolving + // it from the L2 RPC failed (see l2gersync.L2GERSync.GetFirstGERAfterL1InfoTreeIndex) + L2BlockTimestamp *uint64 `json:"-"` } // MarshalJSON is the implementation of the json.Marshaler interface. diff --git a/docs/bridgetracker.md b/docs/bridgetracker.md index 5ba02b870..7a66eb1a1 100644 --- a/docs/bridgetracker.md +++ b/docs/bridgetracker.md @@ -73,10 +73,11 @@ RegisterResolveTimeout = "3s" L1BlockFinality = "LatestBlock" L2BlockFinality = "LatestBlock" MaxTrackedBridges = 100000 +L2InjectionLookbackBlocks = 1000 # Workaround only: uncomment for a destination network whose bridge-service instance does not # report the L2 block a covering GER was injected at. -# [Tracker.L2GlobalExitRootAddrs] +# [Tracker.L2GlobalExitRootAddress] # 1 = "0x..." [Tracker.AgglayerClient] @@ -109,12 +110,15 @@ UseTLS = false - `MaxTrackedBridges`: caps the in-memory supervised list; a request beyond it fails instead of registering the bridge — reaching the cap never evicts an existing entry to make room, so `RetentionPeriod` and `IdleTimeout` are what keep the registry under it during normal operation. -- `L2GlobalExitRootAddrs`: **workaround only** — a networkID → `GlobalExitRootManagerL2` contract +- `L2GlobalExitRootAddress`: **workaround only** — a networkID → `GlobalExitRootManagerL2` contract address map, used solely as a fallback for a destination network whose bridge-service instance does not report the L2 block a covering GER was actually injected at. For a network present here, the tracker scans that network's own L2 for the `UpdateHashChainValue` event instead of leaving it absent. A network absent from this map (the default, empty map) never gets this fallback attempted; it should not be set otherwise. +- `L2InjectionLookbackBlocks`: bounds how many blocks that same fallback scans backwards from the + destination network's head before giving up, instead of continuing all the way back to genesis. + Defaults to 1,000 blocks when unset or `<= 0`. - `AgglayerClient`: the client used to resolve an L2-originated bridge's covering certificate and its status (`PendingInclusion`/`CertificatePending`/`WaitL1SettledGER`). `Cached` is the master switch for `ConfigurationCache`'s per-method policy (`false` ignores it entirely). Each method diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index d1cacfe81..091a8e6d6 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -214,7 +214,7 @@ Carried in the `result` field of a [BridgeStepPath](#bridgesteppath). Its shape | PendingInclusion | `certificate_id` (Hash), `new_ler` (Hash), `previous_ler` (*Hash) | the certificate that first includes the bridge and the LER transition it produced; `previous_ler` is nil for a network's first certificate | | CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data — including `block_number`/`block_timestamp` — once `status` is `done` | | WaitL1SettledGER | `tx_hash` (Hash), `settlement_block_number` (uint64), `settlement_block_timestamp` (uint64), `settlement_log_index` (uint), `ger` (Hash), `ger_block_number` (uint64), `ger_block_timestamp` (uint64), `ger_log_index` (uint), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots, and `ger_block_number`/`ger_block_timestamp`/`ger_log_index` locate the event it was computed from — normally the same block as the settlement, but the closest earlier one on L1 when the settlement tx's own receipt didn't move the GER itself. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | -| WaitingGERInjection | `ger` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | GER injected on the destination network that covers the bridge, and that injection's block | +| WaitingGERInjection | [InjectedGERResult](#injectedgerresult) | GER covering the bridge: the L1 Info Tree leaf it resolves to, and — once known — the actual L2 block/timestamp it was injected at | | Claimed | `claim_tx` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | claim transaction on the destination network, its block and that block's timestamp | | any other step | — | no result: always `nil` | @@ -250,6 +250,68 @@ tracker endpoint or WebSocket message currently serializes it. | ler_type | LERType (int) | 0->NA, 1->Mainnet , 2-> Local | ler_type_string | string | string representation of ler_type (e.g. "Mainnet") +## InjectedGERResult + +The result of `WaitingGERInjection` once it completes: the GER covering the bridge, split into +where it comes from on each side. `l1_info_tree_leaf`'s `block_number`/`block_timestamp` are +always the **L1** `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` event that produced the leaf — never the +block it was actually injected at on the destination network. `l2_injected_ger` carries that +separately, and is the fix for a bug where the L1 block was returned in its place (making the +result useless for calculating L2-side injection timing). + +`l2_injected_ger` is resolved two ways, in order: + +1. Straight from the destination's bridge-service instance (`injected_l2_block_num`/ + `injected_l2_block_timestamp` on `GET /bridge/v1/injected-l1-info-leaf`, see + [REFERENCE_API.md](REFERENCE_API.md)) — the common case. +2. If that instance predates those fields, and the destination network's + `GlobalExitRootManagerL2` contract address is configured (`Tracker.L2GlobalExitRootAddress`, + `bridgetracker/config.go`), the tracker falls back to scanning that network's own + `UpdateHashChainValue` logs backwards from latest until it finds the one that injected this + GER (`GERSource.findL2InjectionBlockBackwards`, `bridgetracker/sources/ger.go`). + +`l2_injected_ger` is **omitted** (no key), not `null`, when neither resolves it — the bridge-service +doesn't report it and either no `L2GlobalExitRootAddress` entry is configured for the network or the +backward scan itself failed or found nothing (logged as a warning, never fails the step). + +| field | type | desc | +| ------|------|------| +| l1_info_tree_leaf.ger | Hash | Global Exit Root covering the bridge | +| l1_info_tree_leaf.block_number | uint64 | L1 block of the event that produced the leaf | +| l1_info_tree_leaf.block_timestamp | uint64 | that L1 block's timestamp | +| l2_injected_ger | *object | **omitted** (no key) while neither resolution path above produces a value | +| l2_injected_ger.block_number | uint64 | L2 block where the GER was actually injected on the destination network | +| l2_injected_ger.block_timestamp | *uint64 | that L2 block's timestamp; **omitted** (no key) — separately from `l2_injected_ger` itself — if it could not yet be resolved (e.g. the bridge-service's own RPC backfill, see [REFERENCE_API.md](REFERENCE_API.md), hasn't succeeded yet), resolving on a later request | + +Example (fully resolved): + +```json +{ + "l1_info_tree_leaf": { + "ger": "0x330d1f1546dc784aa465fdf83fb9d88e0a3778064d74e182c1dfb803ef155c1", + "block_number": 11606405, + "block_timestamp": 1788188124 + }, + "l2_injected_ger": { + "block_number": 11606512, + "block_timestamp": 1788188250 + } +} +``` + +Example (destination bridge-service instance not yet upgraded, or `l2_injected_ger.block_timestamp` +still resolving): + +```json +{ + "l1_info_tree_leaf": { + "ger": "0x330d1f1546dc784aa465fdf83fb9d88e0a3778064d74e182c1dfb803ef155c1", + "block_number": 11606405, + "block_timestamp": 1788188124 + } +} +``` + ## CertificateData This is one of the two response fields that **does** follow the numeric+`_string` convention diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 1af951c0c..60dde7154 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -186,7 +186,8 @@ func runTracker( log.Fatalf("failed to create bridge event source: %v", err) } gerSource := sources.NewGERSource(finder, rpcClients, trackerCfg.L1GlobalExitRootAddress, - trackerCfg.L1BlockFinality, log.WithFields("module", "bridgetracker-gersource")) + trackerCfg.L1BlockFinality, trackerCfg.L2GlobalExitRootAddress, trackerCfg.L2InjectionLookbackBlocks, + log.WithFields("module", "bridgetracker-gersource")) // GET /activity/from/{from_address} scans every network the finder knows about (via // finder.NetworkIDs) for bridges sent by an address, and resolves their claim state through diff --git a/proxy/config/config_test.go b/proxy/config/config_test.go index 45e4b0de3..5cf7a1be8 100644 --- a/proxy/config/config_test.go +++ b/proxy/config/config_test.go @@ -35,6 +35,7 @@ func TestLoadFilesDefaults(t *testing.T) { require.Equal(t, aggkittypes.LatestBlock, cfg.Tracker.L1BlockFinality) require.Equal(t, aggkittypes.LatestBlock, cfg.Tracker.L2BlockFinality) require.Equal(t, 100000, cfg.Tracker.MaxTrackedBridges) + require.Equal(t, uint64(1000), cfg.Tracker.L2InjectionLookbackBlocks) // URL is left unset by default: it must be supplied per-environment (see proxy/scripts) require.Empty(t, cfg.Tracker.AgglayerClient.GRPC.URL) require.True(t, cfg.Tracker.AgglayerClient.Cached) diff --git a/proxy/config/default.go b/proxy/config/default.go index fed5b772b..465d1af8b 100644 --- a/proxy/config/default.go +++ b/proxy/config/default.go @@ -80,6 +80,11 @@ L2BlockFinality = "LatestBlock" # RetentionPeriod and IdleTimeout are what keep the registry under it during normal operation. MaxTrackedBridges = 100000 +# L2InjectionLookbackBlocks: how many blocks the L2GlobalExitRootAddress fallback scans backwards +# from the destination network's head before giving up, instead of continuing all the way back +# to genesis. +L2InjectionLookbackBlocks = 1000 + [Tracker.AgglayerClient] Cached = true [Tracker.AgglayerClient.ConfigurationCache] From 0c021aab97766befe258defad8a76462007230b9 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:42:39 +0200 Subject: [PATCH 14/16] fix(bridgetracker): fix off-by-one in backwards UpdateL1InfoTree block range findEventUpdateL1InfoTreeBackwards computed fromBlockChunk as toBlock - l1InfoTreeBackwardsSearchChunkSize, which spans chunkSize+1 blocks since FromBlock/ToBlock are both inclusive (e.g. [15000,25000] for a 10_000 chunk). RPC providers enforcing a strict 10_000-block eth_getLogs limit rejected every such query, leaving any settlement without its own UpdateL1InfoTree event stuck in error state. Add the missing +1 (and widen the guard to toBlock >= chunkSize accordingly) so each chunk is exactly l1InfoTreeBackwardsSearchChunkSize blocks. Update the settlement_test.go helper/paginated test that mirrored the same calculation. Co-Authored-By: Claude Sonnet 5 --- bridgetracker/sources/settlement.go | 8 ++++++-- bridgetracker/sources/settlement_test.go | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/bridgetracker/sources/settlement.go b/bridgetracker/sources/settlement.go index 0a7e31c47..72f5c7ed8 100644 --- a/bridgetracker/sources/settlement.go +++ b/bridgetracker/sources/settlement.go @@ -188,8 +188,12 @@ func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( toBlock := fromBlock for { fromBlockChunk := uint64(0) - if toBlock > l1InfoTreeBackwardsSearchChunkSize { - fromBlockChunk = toBlock - l1InfoTreeBackwardsSearchChunkSize + if toBlock >= l1InfoTreeBackwardsSearchChunkSize { + // FromBlock/ToBlock are both inclusive, so the naive toBlock-chunkSize would span + // chunkSize+1 blocks (e.g. [15000,25000] for a 10_000 chunk) — the +1 keeps each + // query at exactly chunkSize blocks, which matters for providers that reject an + // eth_getLogs range wider than that + fromBlockChunk = toBlock - l1InfoTreeBackwardsSearchChunkSize + 1 } logs, err := client.FilterLogs(ctx, ethereum.FilterQuery{ diff --git a/bridgetracker/sources/settlement_test.go b/bridgetracker/sources/settlement_test.go index d77eb032f..c3a31c0a2 100644 --- a/bridgetracker/sources/settlement_test.go +++ b/bridgetracker/sources/settlement_test.go @@ -28,11 +28,11 @@ func newSettlementSource(client *mocks.BaseEthereumClienter) *SettlementSource { // expectBackwardsUpdateL1InfoTreeLog stubs client's FilterLogs to answer // findEventUpdateL1InfoTreeBackwards' very first chunk (fromBlock down to fromBlock minus -// l1InfoTreeBackwardsSearchChunkSize, or 0) with a single matching log +// l1InfoTreeBackwardsSearchChunkSize plus one, or 0) with a single matching log func expectBackwardsUpdateL1InfoTreeLog(client *mocks.BaseEthereumClienter, fromBlock uint64, log gethtypes.Log) { fromChunk := uint64(0) - if fromBlock > l1InfoTreeBackwardsSearchChunkSize { - fromChunk = fromBlock - l1InfoTreeBackwardsSearchChunkSize + if fromBlock >= l1InfoTreeBackwardsSearchChunkSize { + fromChunk = fromBlock - l1InfoTreeBackwardsSearchChunkSize + 1 } client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ FromBlock: new(big.Int).SetUint64(fromChunk), @@ -267,12 +267,12 @@ func TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent(t *testing.T) func TestFindEventUpdateL1InfoTreeBackwardsPaginates(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ - FromBlock: big.NewInt(15000), ToBlock: big.NewInt(25000), + FromBlock: big.NewInt(15001), ToBlock: big.NewInt(25000), Addresses: []common.Address{testGERAddress}, Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, }).Return(nil, nil) client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ - FromBlock: big.NewInt(4999), ToBlock: big.NewInt(14999), + FromBlock: big.NewInt(5001), ToBlock: big.NewInt(15000), Addresses: []common.Address{testGERAddress}, Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, }).Return([]gethtypes.Log{ From ce78dabaa87040dd5f705ec9a20620c0ed4a82af Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:48:54 +0200 Subject: [PATCH 15/16] fix(bridgetracker): exclude same-block GER updates after the settlement log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findEventUpdateL1InfoTreeBackwards picked the last UpdateL1InfoTree log in its query range as "most recent", even when that log came from a later transaction in the settlement's own block. If another tx in that block emits UpdateL1InfoTree after the settlement's own log index, its GER did not exist yet when the settlement executed, so the tracker was associating the certificate with the wrong GER/leaf index. Thread settlementLogIndex through to findEventUpdateL1InfoTreeBackwards and filter out any log sharing fromBlock with the settlement at or after that index before picking the latest one — mirroring the position filtering GERSource.FindFirstL1InfoTreeAfterBlock already does in ger.go, just looking backwards instead of forwards. Co-Authored-By: Claude Sonnet 5 --- bridgetracker/sources/settlement.go | 33 +++++++++++++++----- bridgetracker/sources/settlement_test.go | 39 +++++++++++++++++++++--- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/bridgetracker/sources/settlement.go b/bridgetracker/sources/settlement.go index 72f5c7ed8..325e2849e 100644 --- a/bridgetracker/sources/settlement.go +++ b/bridgetracker/sources/settlement.go @@ -153,7 +153,8 @@ func (s *SettlementSource) SettlementGERUpdate( // receipt): the value it propagated is whichever one an earlier settlement already // established, so look backwards on L1 for that event if !result.HasUpdateL1InfoTree { - event, err := s.findEventUpdateL1InfoTreeBackwards(ctx, client, receipt.BlockNumber.Uint64()) + event, err := s.findEventUpdateL1InfoTreeBackwards( + ctx, client, receipt.BlockNumber.Uint64(), result.SettlementLogIndex) if err != nil { return nil, err } @@ -176,14 +177,18 @@ type updateL1InfoTreeEvent struct { } // findEventUpdateL1InfoTreeBackwards looks for the most recent UpdateL1InfoTree event on the -// GlobalExitRoot contract at or before fromBlock, walking backwards in -// l1InfoTreeBackwardsSearchChunkSize chunks until one is found or block 0 is reached. Only -// called when the settlement tx's own receipt does not carry the event (see -// SettlementGERUpdate): the L1 Global Exit Root is never unset, so some earlier update always -// exists, unless the settlement tx is not what it claims to be, in which case this returns -// domain.ErrBadSettlementTx (Permanent — see the sentinel's own doc) +// GlobalExitRoot contract at or before (fromBlock, settlementLogIndex) — the settlement tx's own +// position — walking backwards in l1InfoTreeBackwardsSearchChunkSize chunks until one is found +// or block 0 is reached. settlementLogIndex excludes any log that shares fromBlock with the +// settlement but sits at or after it: such a log comes from a later transaction in the same +// block, so the GER it produced did not exist yet when the settlement executed (mirrors the +// position filtering FindFirstL1InfoTreeAfterBlock already does in ger.go, just looking +// backwards instead of forwards). Only called when the settlement tx's own receipt does not +// carry the event (see SettlementGERUpdate): the L1 Global Exit Root is never unset, so some +// earlier update always exists, unless the settlement tx is not what it claims to be, in which +// case this returns domain.ErrBadSettlementTx (Permanent — see the sentinel's own doc) func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( - ctx context.Context, client aggkittypes.BaseEthereumClienter, fromBlock uint64, + ctx context.Context, client aggkittypes.BaseEthereumClienter, fromBlock uint64, settlementLogIndex uint, ) (*updateL1InfoTreeEvent, error) { toBlock := fromBlock for { @@ -207,6 +212,18 @@ func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( fromBlockChunk, toBlock, err) } + // Only the settlement's own block (fromBlock) can carry a log at or after + // settlementLogIndex — every other block queried here is strictly earlier — so this + // only ever trims the first chunk + filtered := logs[:0] + for _, l := range logs { + if l.BlockNumber == fromBlock && l.Index >= settlementLogIndex { + continue + } + filtered = append(filtered, l) + } + logs = filtered + if len(logs) > 0 { // FilterLogs returns logs in ascending block/log-index order, so the last one is the // most recent event in this chunk — the closest one at or before fromBlock diff --git a/bridgetracker/sources/settlement_test.go b/bridgetracker/sources/settlement_test.go index c3a31c0a2..7221b08ea 100644 --- a/bridgetracker/sources/settlement_test.go +++ b/bridgetracker/sources/settlement_test.go @@ -283,13 +283,44 @@ func TestFindEventUpdateL1InfoTreeBackwardsPaginates(t *testing.T) { }, nil) expectBackwardsBlockTimestamp(client) - event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 25000) + event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 25000, 0) require.NoError(t, err) require.Equal(t, &updateL1InfoTreeEvent{ GER: wantGER, BlockNumber: 10000, BlockTimestamp: testBackwardsBlockTimestamp, LogIndex: 2, }, event) } +// TestFindEventUpdateL1InfoTreeBackwardsExcludesLaterLogInSettlementBlock pins that a match in +// the settlement's own block (fromBlock) is only accepted at or before settlementLogIndex: a log +// there from a later transaction produced a GER that did not exist yet when the settlement +// executed, so it must be skipped in favor of an earlier chunk's match +func TestFindEventUpdateL1InfoTreeBackwardsExcludesLaterLogInSettlementBlock(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{ + { + BlockNumber: 4000, Index: 2, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, + { + // same block as the settlement (fromBlock=5000), but at/after its log index (3): a + // later transaction's GER update, not yet in effect when the settlement executed + BlockNumber: 5000, Index: 3, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, + }, nil) + expectBackwardsBlockTimestamp(client) + + event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 3) + require.NoError(t, err) + require.Equal(t, &updateL1InfoTreeEvent{ + GER: wantGER, BlockNumber: 4000, BlockTimestamp: testBackwardsBlockTimestamp, LogIndex: 2, + }, event) +} + // TestFindEventUpdateL1InfoTreeBackwardsNotFound pins that reaching block 0 without a match is // domain.ErrBadSettlementTx: the L1 Global Exit Root is never unset, so its absence anywhere // before fromBlock means the settlement tx is not what it claims to be @@ -301,7 +332,7 @@ func TestFindEventUpdateL1InfoTreeBackwardsNotFound(t *testing.T) { Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, }).Return(nil, nil) - _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) require.ErrorIs(t, err, domain.ErrBadSettlementTx) } @@ -315,7 +346,7 @@ func TestFindEventUpdateL1InfoTreeBackwardsMalformedLog(t *testing.T) { {BlockNumber: 4000, Topics: []common.Hash{updateL1InfoTreeSignature}}, }, nil) - _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) require.ErrorIs(t, err, domain.ErrBadSettlementTx) } @@ -325,7 +356,7 @@ func TestFindEventUpdateL1InfoTreeBackwardsFetchError(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return(nil, errors.New("rpc down")) - _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000) + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) require.ErrorContains(t, err, "rpc down") } From a6036601244b6b34460529234b057b1e8ab3a125 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:56:40 +0200 Subject: [PATCH 16/16] test(bridgetracker): cover the isClaimed()-true-but-not-indexed-yet window Addresses review feedback (item 6, issuecomment-5506110812) on the StepWaitingClaim/StepClaimed split: nothing pinned the tick where the on-chain isClaimed() check goes true but the destination bridge service has not indexed the claim tx yet, so StepWaitingClaim completes while StepClaimed stays its own current step (InProgress, no result) instead of being auto-completed alongside it. - domain/resolve_steps_test.go: new TestResolveStepsClaimedNotIndexedYet, exercising ResolveSteps directly against the fakeFacts port. - engine_test.go: TestEngineLifecycleL2ToL2 now ticks through that window (claimed=true, claim=nil) before the bridge service indexes the claim tx on the following tick, asserting both steps' status at each point. Co-Authored-By: Claude Sonnet 5 --- bridgetracker/domain/resolve_steps_test.go | 37 ++++++++++++++++++++++ bridgetracker/engine_test.go | 19 +++++++++++ 2 files changed, 56 insertions(+) diff --git a/bridgetracker/domain/resolve_steps_test.go b/bridgetracker/domain/resolve_steps_test.go index bb605df8a..512b552e6 100644 --- a/bridgetracker/domain/resolve_steps_test.go +++ b/bridgetracker/domain/resolve_steps_test.go @@ -448,6 +448,43 @@ func TestResolveSteps(t *testing.T) { } } +// TestResolveStepsClaimedNotIndexedYet pins the window between isClaimed() going true on-chain +// and the destination bridge service indexing the claim tx: StepWaitingClaim completes in this +// same call — its on-chain check needs nothing else — but StepClaimed, which still needs its own +// ClaimFor fact (nil here, i.e. not indexed yet), stays the current step instead of being +// auto-completed alongside it (see UpdateStep's doc, "including StepClaimed, which gets its own +// resolver call") +func TestResolveStepsClaimedNotIndexedYet(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) + tracking := newTracking(types.BridgeTypeL2ToL2, []BridgeStepPath{ + {Step: types.StepWaitingLERUpdate, Status: types.StepStatusDone}, + {Step: types.StepPendingInclusion, Status: types.StepStatusDone}, + {Step: types.StepCertificatePending, Status: types.StepStatusDone}, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusDone}, + {Step: types.StepWaitingClaim, Status: types.StepStatusInProgress}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, now) + facts := fakeFacts{claimed: true} // isClaimed() -> true; ClaimFor (claim) left nil: not indexed yet + + result, err := ResolveSteps(context.Background(), log.NewLoggerNil(), testResolvers(&facts), tracking, now) + require.NoError(t, err) + require.Equal(t, []string{"isClaimed", "claimFor"}, facts.queried) + + steps := result.AllSteps() + waitingClaim := steps[indexOfStep(steps, types.StepWaitingClaim)] + require.Equal(t, types.StepStatusDone, waitingClaim.Status, "on-chain isClaimed() is authoritative on its own") + + claimed := steps[indexOfStep(steps, types.StepClaimed)] + require.Equal(t, types.StepStatusInProgress, claimed.Status, "still waiting on its own ClaimFor fact") + require.Nil(t, claimed.Result(), "no claim tx indexed yet") + + idx := result.StepIndex() + require.NotNil(t, idx) + require.Equal(t, types.StepClaimed, steps[*idx].Step) +} + func TestResolveStepsErrors(t *testing.T) { t.Parallel() diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 8d951b047..37f788886 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -597,7 +597,26 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { } } + // isClaimed() goes true on-chain a tick before the bridge service has indexed the claim tx: + // StepWaitingClaim completes on its own, but StepClaimed stays the current step rather than + // being auto-completed alongside it f.claimed = true + engine.tick(t.Context()) + tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) + require.False(t, tracking.Failed()) + allSteps = tracking.AllSteps() + require.Equal(t, types.StepClaimed, allSteps[*tracking.StepIndex()].Step, + "on-chain isClaimed() completed WaitingClaim, but Claimed still needs its own ClaimFor fact") + for _, sp := range allSteps { + switch sp.Step { + case types.StepWaitingClaim: + require.Equal(t, types.StepStatusDone, sp.Status, "on-chain isClaimed() is authoritative on its own") + case types.StepClaimed: + require.Equal(t, types.StepStatusInProgress, sp.Status) + require.Nil(t, sp.Result(), "no claim tx indexed yet") + } + } + f.claim = &types.ClaimResult{ClaimTx: common.HexToHash("0x03"), BlockNumber: 30} engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash})