fix: aggkit-proxy: bali integration: bridge tracker activity endpoint, GER settlement/removal fixes, dead-network handling - #1815
Conversation
Config.IgnoreNetworkIDs lists networkIDs (rollupIDs) that are excluded entirely from on-chain resolution: buildInitialCache skips them during enumeration (no RollupIDToRollupData call, no contract reads, no health probe) and the live listener skips them when a rollup-manager lifecycle event announces them (CreateNewRollup/CreateNewAggchain/AddExistingRollup). This avoids known-dead networks (decommissioned or unreachable test rollups) slowing down proxy startup and event processing with on-chain reads and health-check timeouts that can never succeed. A static Config.BridgeURLs override for a networkID is still served even if that networkID is also listed in IgnoreNetworkIDs. Fixes #1809
Documents the new bridgeservicefinder.Config.IgnoreNetworkIDs field in the proxy binary's default TOML template. No other doc location lists BridgeServiceFinder fields individually for the proxy binary (only docs/autoclaim.md has a per-field table, already updated for AutoClaim).
…alth probe probeAll iterated every cache entry unconditionally, including one installed by the config-seeding step for a networkID that is both config-overridden and listed in IgnoreNetworkIDs. That defeated the point of ignoring a known-dead network: its /health probe still incurred the configured timeout, and under RequireAllHealthyOnStart=true an unreachable ignored override could still fail Start with ErrServicesUnhealthyOnStart. probeAll now skips any networkID in Config.IgnoreNetworkIDs; its entry is still served by GetURL, with healthy defaulting to false (never probed). Found by review on #1810.
Add the missing BridgeServiceFinder fields (BlockFinality, BlockChunkSize, HealthCheckPath, HealthCheckTimeout, RequireAllHealthyOnStart, IgnoreNetworkIDs) and the empty BridgeURLs/RPCURLs map sections to config/default.go, matching the values already used in proxy/config/default.go and the Default* constants defined in bridgeservicefinder/config.go. Also add the missing BridgeURLs/RPCURLs sections to proxy/config/default.go for the same reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l-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.
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.
…r ~30min" This reverts commit bf18a77.
…d of wall-clock" This reverts commit e01e556.
…ateL1InfoTree event 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 <noreply@anthropic.com>
isGERRemovedFromL2 scans for the (S-log) removal event from the insert block, which can be arbitrarily far behind the head, up to "latest" (open-ended eth_getLogs). Some RPC providers reject that open-ended query once the insert block is more than their max-block-range cap behind the head, with e.g. "query exceeds max block range 100000". Before this fix, that error was only logged and treated as "not removed" forever, so a stale insert could never be unstuck once the chain advanced past the provider's cap. scanRemovedGERs now detects that specific error via aggkitcommon.ParseMaxRangeFromError, resolves the current head, and retries the scan chunked via aggkitcommon.ChunkedRangeQuery - the same pattern already used by L2EVMGERReader.GetRemovedGERsForRange and AgglayerBridgeL2Reader's unset-claims fallback chunking. The learned range cap is cached on the downloader (removalScanMaxRange) so that, once learned, subsequent retries (this runs on every appender retry while a GER stays unresolved) go straight to the chunked path instead of repeating the doomed unbounded call - and its ERROR log - forever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3712ed8ae8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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...) |
There was a problem hiding this comment.
Preserve the bridge transaction's source network
Preserve networkID with each scanned bridge instead of discarding it here. For a return bridge made on L2 with an L1-origin token, BridgeResponse.OriginNetwork is 0 while the transaction's source network encoded in GlobalIndex is the L2; downstream code consequently calls IsClaimed(..., 0), registers the transaction for tracking on L1, and reports the wrong bridge_network_id. Carry the scanned network ID through the activity entry or decode it from GlobalIndex.
Useful? React with 👍 / 👎.
| if toBlock > l1InfoTreeBackwardsSearchChunkSize { | ||
| fromBlockChunk = toBlock - l1InfoTreeBackwardsSearchChunkSize | ||
| } |
There was a problem hiding this comment.
Limit each inclusive log query to 10,000 blocks
Because FromBlock and ToBlock are inclusive, subtracting 10,000 produces a 10,001-block query (for example, [15000,25000]). On providers enforcing the stated 10,000-block eth_getLogs limit, every backward lookup fails immediately, so settlements without an UpdateL1InfoTree in their own receipt remain in error instead of resolving. Compute the lower bound with toBlock - chunkSize + 1.
Useful? React with 👍 / 👎.
| 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] |
There was a problem hiding this comment.
Exclude GER updates after the settlement log
When another transaction emits UpdateL1InfoTree later in the same block as a settlement whose own receipt has no such event, selecting the last log in the block returns a GER that did not exist when the settlement executed. The tracker then associates the certificate with the wrong GER and leaf index. Filter same-block logs to those before SettlementLogIndex (analogous to the position filtering already used by GERSource) before choosing the latest event.
Useful? React with 👍 / 👎.
…k IDs, auto bridge address, incremental cache
Builds on the GET /activity/from/{from_address} endpoint (3712ed8) 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 <noreply@anthropic.com>
… endpoint, document it - 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 <noreply@anthropic.com>
…block timestamps
- 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.
🔄 Changes Summary
feat(bridgetracker): newGET /tracker/v1/activity/from/{from_address}endpoint that scans every bridge service known to thebridgeservicefinder(via newFinder.NetworkIDs()) for bridges sent by an address and resolves each one's claim state (ActivityCache,ActivitySource,ActivityQuerier/ActivityBridgeScanner/ActivityClaimCheckerports). Opt-in viaincludeTracking=trueto also register still-unclaimed bridges with the tracker. Endpoint is only registered when wired (proxy/cmd/run.go), and swagger docs are regenerated.feat(bridgetracker):StepClaimedis now its own tracked step with a dedicatedClaimedResolverthat fetches the claim tx/block from the destination network's bridge service, decoupled fromStepWaitingClaim, which now checksisClaimed()on-chain directly through a newClaimCheckerport instead of waiting on the indexed claim record — faster and authoritative, at the cost of not carrying claim tx details itself (that's what the newClaimedstep result is for). The on-chain binding/cache logic is factored out ofActivitySourceintosources/claim_checker.go(contractClaimCheckers) and shared by both the tracker engine and the activity endpoint.fix(bridgetracker): introducedomain.ScannedBridgeto track which network's bridge service actually reported a scanned bridge (NetworkID), distinct fromBridge.OriginNetwork(the bridged asset's origin network) — the two diverge when an asset is re-bridged across more than one hop, which was feeding the wrongsourceBridgeNetworkintoisClaimed()and the wrong network intoTrackingID/the activity endpoint'sbridge_network_idfor such bridges.feat(bridgetracker): newGET /bridge-address[/{network_id}]endpoint, resolving the bridge contract address for one network or every network currently known — opt-in viaConfig.BridgeAddressResolver, wired inproxy/cmd/run.gooffbridgeservicefinder.Finderdirectly.feat(bridgetracker):ClaimResultandInjectedGERResultnow also carryBlockTimestampalongsideBlockNumber.fix(l2gersync):isGERRemovedFromL2's open-ended removal scan now recovers from RPC providers that capeth_getLogsblock ranges ("query exceeds max block range ..."), falling back to a chunked scan and caching the learned cap so retries don't repeat the doomed unbounded call (and its ERROR log) forever.fix(bridgetracker): resolve the settled GER correctly when the settlement tx has noUpdateL1InfoTreeevent.feat(bridgeservicefinder):IgnoreNetworkIDsconfig to skip known-dead networks entirely during on-chain enumeration and the live listener, exempting them from the startup health probe; default config updated inconfig/default.goandproxy/config/default.go.BridgeServiceFinder.IgnoreNetworkIDs,Config.BridgeAddressResolverand the newbridgetrackeractivity/bridge-address ports are additive/opt-in.📋 Config Updates
IgnoreNetworkIDs = []under[BridgeServiceFinder]/[AutoClaim.BridgeServiceFinder](defaults to empty, no behavior change unless set).Config.BridgeAddressResolver(Go-level wiring, not a TOML key) gating theGET /bridge-address[/{network_id}]endpoint; unset leaves both routes unregistered.✅ Testing
go build ./...andgo test ./bridgetracker/... ./bridgeservicefinder/... ./l2gersync/... ./autoclaim/...pass, including new/updated regression tests for the chunked GER-removal scan (evm_downloader_sovereign_test.go), the activity endpoint (activity_test.go,sources/activity_test.go,cache_test.go), the claimed-step split (resolve_steps_test.go,engine_test.go), and the new bridge-address endpoint (bridge_address_test.go).🐞 Issues
🔗 Related PRs
📝 Notes
develop(bridgeservicefinderIgnoreNetworkIDs, bridgetracker GER settlement fix) that had not yet been merged.