Skip to content

feat: auto claim L2->L1 and L2->L2 support#1705

Draft
arnaubennassar wants to merge 31 commits into
developfrom
feat/autoclaim-l2-lx
Draft

feat: auto claim L2->L1 and L2->L2 support#1705
arnaubennassar wants to merge 31 commits into
developfrom
feat/autoclaim-l2-lx

Conversation

@arnaubennassar

@arnaubennassar arnaubennassar commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

🚧 DRAFT — interim base branch, do not merge yet 🚧

This PR is a draft. It targets feat/bridge-service-finder (the head branch of #1692,
feat: add bridgeservicefinder package) as an interim base, not develop, because this
branch was itself created on top of feat/bridge-service-finder and also depends on #1691
(feat: add op-pp-2chains env and L2→L2 bridge test) — both still open.

Merge order / what must happen before this is ready for real review:

  1. feat: add op-pp-2chains env and L2→L2 bridge test #1691 merges into develop.
  2. feat: add bridgeservicefinder package #1692 merges into develop.
  3. Once both have landed, this PR's base is retargeted from feat/bridge-service-finderdevelop,
    the draft flag is removed, and it goes up for review for real.

This branch already contains #1691's code via a direct branch merge (commit c3e4e0f2, merging
origin/feat/e2e-op-pp-2chains#1691's head — directly into this feature branch, per an explicit
decision to unblock this plan's own L2→L2 e2e work without waiting for #1691 to land first). That
merge brought #1691's diff onto this branch as-is; it was not re-authored or re-reviewed here.
Reviewers: please do not review or merge this PR until #1691 and #1692 are both confirmed merged
to develop and it has been retargeted there
— merging it as-is (against
feat/bridge-service-finder, before either lands) would land unreviewed code from both of those
PRs on develop attributed to this one, and cause conflicts/duplication when they merge on their
own.

🔄 Changes Summary

  • Extends the existing Auto Claim service (L1→L2 only, from feat: autoclaim plan #1662) with automatic claiming for the other two
    bridge directions:
    • L2→L1: bridge exits initiated on a rollup, destined for L1 (destination network 0).
    • L2→L2: bridge exits initiated on one rollup, destined for another rollup.
    • L1→L2 behavior is unchanged in shape — same detector, same claim path — but its proof-readiness gate now
      goes through the destination bridge service instead of a local GER syncer (see below); regression-tested end
      to end (see Testing).
  • The node running Auto Claim does not sync any source L2 directly. A new bridgedetector.L2ToLx detector
    watches l1infotreesync for each source rollup's local exit root (LER) advancing (from both
    VerifyBatchesTrustedAggregator, zkEVM/state-transition rollups, and the newly-indexed
    VerifyPessimisticStateTransition, pessimistic/aggchain rollups — the latter previously wasn't indexed by
    l1infotreesync at all), then fetches the new bridges from that source rollup's own bridge service via a new
    bridgeservicefinder-resolved REST call.
  • A new bridge service endpoint, GET /bridge/v1/claim-candidates, serves exactly that: bridges in a deposit-count
    range, filtered by destination network. Backed by a new deposit-range+destination query in bridgesync
    (GetBridgesInDepositRange). It does not return a per-bridge Merkle proof — see "GER gate + claim-time proof"
    below.
  • GER-injection gate via the destination bridge service, replacing per-claimer l2gersync. Auto Claim no
    longer runs a per-claimer l2gersync instance (no isolated per-claimer SQLite database, no per-claimer L2 reorg
    detector, no per-claimer L2 RPC client dedicated to GER tracking). Instead, during proof preparation, readiness
    for an L2-destination claimer is gated by calling that destination network's own aggkit bridge service GET /bridge/v1/injected-l1-info-leaf (resolved through bridgeservicefinder.Finder). This applies uniformly to
    both directions, including L1→L2. A claimer whose destination is L1 (NetworkID = 0) has no such gate — it
    is ready as soon as l1infotreesync has the relevant leaf, since the GER already exists in the L1 GER manager by
    construction.
  • Leaf→LER proof fetched only at claim time. The L2ToLx detector no longer fetches or stores the leaf→LER
    Merkle proof at discovery time — claim-candidates never returned it in this version (see above). The new
    rollup-origin proof preparer (autoclaim/proof.RollupPreparer) builds the LER→rollup-exit-root proof locally
    from l1infotreesync and always fetches a fresh leaf→LER proof from the source's bridge service (GET /bridge/v1/claim-proof) when preparing a claim — there is no "staleness" special case, because the proof is
    never cached in the first place. SourceAwarePreparer dispatches every claimer between this and the existing
    L1-origin preparer based on each request's source_network, not the claimer's destination.
  • Claim submission (claimtx, claimer.IsClaimed, global index derivation) is now keyed by source_network
    instead of always assuming L1 origin. The claim ABI is byte-identical across L1 and L2 v2 bridge contracts, so
    claimAsset/claimMessage packing is reused unchanged.
  • A destination network 0 (L1) claimer is now a legal, fully-supported configuration — like every other claimer,
    it has no l2gersync, and additionally it is the only claimer type with no GER-injection gate at all (the GER
    already exists in the L1 GER manager by construction once l1infotreesync has the leaf).
  • Request identity changed from origin_network:destination_network:deposit_count (origin = bridged token's
    origin, which isn't unique across multiple source networks) to source_network:destination_network:deposit_count
    (the network the bridge exit was initiated on — this is also what the claim global index encodes).

⚠️ Breaking Changes

  • 🛠️ Config: [AutoClaim.L2ToLxBridgeDetector] is no longer a permanently-disabled placeholder — it is a
    real, independently-enableable detector. A new [AutoClaim.BridgeServiceFinder] section (embeds
    bridgeservicefinder.Config) is required when L2ToLxBridgeDetector.Enabled = true or when any enabled
    claimer has an L2 destination (NetworkID != 0), in either direction
    — this includes a pure L1→L2 setup with
    the L2ToLx detector disabled: Validate() now enforces RollupManagerAddr whenever any enabled claimer targets
    an L2 destination, not just when the detector is on. [[AutoClaim.Claimers]] now accepts NetworkID = 0 as a
    valid L1-destination claimer (requires the L2ToLx detector to be reachable).
  • 🛠️ Config: Per-claimer [[AutoClaim.Claimers]].BlockFinality and .InitialBlockNum are removed — they
    configured the now-deleted per-claimer l2gersync. Autoclaim no longer consumes [L2GERSync]/
    [ReorgDetectorL2] at all (those sections remain, unchanged, for the node-global l2gersync that backs the
    bridge service and Aggoracle).
  • ⚙️ Operational: L1→L2 auto claiming now requires [AutoClaim.BridgeServiceFinder] to be configured
    (with RollupManagerAddr) and the destination network's own bridge service to be reachable, whenever the
    destination is an L2. Previously an L1→L2 claimer only needed the shared [L2GERSync]/[ReorgDetectorL2]
    settings and its own RPC client; there was no dependency on a remote bridge service for GER readiness.
  • 🔌 API: GET /bridge/v1/claim-candidates no longer returns a per-bridge Merkle proof — the response is
    {"claim_candidates": [{"bridge": {...}}], "count": N} (previously each candidate also carried
    proof_local_exit_root and local_exit_root). to_ler/from_ler still define the deposit-count range as
    request parameters; they are unaffected.
  • 🔌 API: GET /bridge/v1/injected-l1-info-leaf now returns 404 Not Found (previously 500 Internal Server Error) when no injected global exit root covers the requested L1 info tree index yet. The Go client
    (bridgeservice/client.Client.GetInjectedL1InfoLeaf) surfaces this as client.ErrNotFound instead of a generic
    wrapped error. Existing e2e retry loops already treated any error from this call as "not ready, retry" and are
    unaffected.
  • 🔌 API: Auto Claim request IDs (GET /autoclaim/v1/bridges/{id}) changed format from
    origin_network:destination_network:deposit_count to source_network:destination_network:deposit_count. For
    every pre-existing L1→L2 request these are numerically identical (source_network is always 0 for L1→L2,
    same as origin_network was for L1-origin tokens) — but any old client that assumed "origin network" semantics
    for a wrapped token bridged L1→L2 would have seen a different (bug-fixed) key than before; see Migration.
  • 🗄️ Storage: autoclaim_request no longer has a leaf_proof_json column (never shipped — see Migration).
    Any external tooling reading the SQLite database directly must drop references to it; ler and
    verify_block_num are unaffected.
  • No breaking changes to claimAsset/claimMessage calldata or the admin approve/reject endpoints.

📋 Config Updates

[AutoClaim.L2ToLxBridgeDetector]
Enabled = true
StartL1Block = 0            # 0 = full history; else initial from_ler derived via GER at this block
PollInterval = "3s"
RetryAfterErrorPeriod = "1s"
MaxRetryAttemptsAfterError = -1

[AutoClaim.BridgeServiceFinder]
RollupManagerAddr = "0x..."       # required when L2ToLxBridgeDetector.Enabled = true, OR when any
                                   # enabled claimer (either direction) has an L2 destination
PollInterval = "30s"
# BlockFinality, BlockChunkSize, HealthCheckPath, HealthCheckTimeout, RequireAllHealthyOnStart all default.

[AutoClaim.BridgeServiceFinder.URLs]
# Static override map, source network ID -> bridge service base URL.
# Required to reach network 0 (L1), which is never enumerated on-chain:
# 0 = "http://static-override-l1:5577"

# Every claimer uses this same [[AutoClaim.Claimers]] shape, including an L1-destination one
# (NetworkID = 0, BridgeAddr = the L1 bridge contract, URLRPC = an L1 RPC endpoint). There are no
# BlockFinality/InitialBlockNum keys anymore (removed with the per-claimer l2gersync); an
# L2-destination claimer's readiness gate is served by [AutoClaim.BridgeServiceFinder] + the
# destination's own bridge service instead.
[[AutoClaim.Claimers]]
Enabled = true
NetworkID = 0
...

Full reference: docs/autoclaim.md (Configuration section, "Top-level keys" and "Claimer keys" tables).
[AutoClaim.L2ToLxBridgeDetector].DisabledBridgeDetector placeholder is gone — both directions are independently
enabled/disabled the same way (Enabled = true/false).

✅ Testing

Unit tests (make test-unit, full repo green, exit 0, zero failures anywhere), coverage for every package this
plan touched or added:

Package Coverage
autoclaim/api 90.0%
autoclaim/apitypes 96.9%
autoclaim/bridgedetector 66.4%
autoclaim/claimer 84.4%
autoclaim/claimtx 89.5%
autoclaim/config 98.4%
autoclaim/policy 97.0%
autoclaim/proof 81.6%
autoclaim/runtime 60.6%
autoclaim/sender 81.4%
autoclaim/simulator 95.0%
autoclaim/storage 74.0%
autoclaim/storage/migrations 100.0%
autoclaim/types 100.0%
bridgeservice 92.9%
bridgeservice/client 92.5%
bridgesync 77.5%
bridgesync/migrations 88.1%
bridgesync/types 92.3%
l1infotreesync 72.0%
config 87.5%

make build, make lint (in-scope packages clean — see Notes), make generate-mocks, and
make generate-swagger-docs all run clean with no resulting uncommitted diff.

  • 🤖 Automatic (e2e, dockerized):
    • Single-chain op-pp environment, one full green run (go test -v -run 'TestAutoClaimL1ToL2AllowAll|TestAutoClaimL1ToL2APIApprove|TestAutoClaimL1ToL2BasicFilter|TestAutoClaimL2ToL1AllowAll' -timeout 30m -count=1 ./test/e2e, exit 0, 300.06s total):
      • TestAutoClaimL1ToL2AllowAll — PASS (53.28s) — L1→L2 regression, now exercising the destination-bridge-service
        GER gate instead of the old per-claimer l2gersync.
      • TestAutoClaimL1ToL2APIApprove — PASS (55.64s) — L1→L2 regression, manual approval flow.
      • TestAutoClaimL1ToL2BasicFilter — PASS (54.22s) — L1→L2 regression, gas-simulation policy.
      • TestAutoClaimL2ToL1AllowAll — PASS (80.04s) — new L2→L1 direction: L2ToLx detector,
        RollupPreparer, an NetworkID = 0 (L1-destination) claimer, end to end against a pessimistic-proof
        rollup (VerifyPessimisticStateTransition LER indexing exercised for real).
    • Two-chain op-pp-2chains environment (AGGKIT_E2E_ENV=op-pp-2chains), TestAutoClaimL2ToL2AllowAll run
      twice consecutively with -count=1 (per this repo's Go test-result caching):
      • Run 1 — PASS (139.76s), suite ok in 286.13s.
      • Run 2 — PASS (140.78s), suite ok in 285.27s.
      • Exercises the L2ToLx detector + bridgeservicefinder resolving both source and destination networks, the
        destination-bridge-service GER-injection gate, and the claim-time leaf-proof fetch path end to end.
    • All docker-compose environments were torn down cleanly after every run; final git status/docker ps clean.

🐞 Issues

🔗 Related PRs

📝 Notes

  • Migration: autoclaim/storage/migrations/autoclaim0002.sql adds source_network to autoclaim_request
    (existing rows default to 0, correct since they're all L1-origin), replaces the UNIQUE(origin_network, destination_network, deposit_count) constraint with UNIQUE(source_network, destination_network, deposit_count) (the real claim identity — it's what the global index encodes), re-keys every pre-existing
    autoclaim0001 row's request_key to the new source:destination:deposit_count format (a no-op value change
    for existing rows, since source_network = origin_network = 0 for all of them), adds ler/verify_block_num
    columns, and adds a new autoclaim_ler_cursor table. This re-key is safe: autoclaim0001
    (introduced by feat: autoclaim plan #1662, merged this week) has not shipped in any tagged release or release branch — confirmed
    against git tag/release/* branches before implementing. The same unshipped migration briefly added, then
    had removed in place, a leaf_proof_json column: once the leaf→LER proof became always-fetch-at-claim-time
    (never stored), the column was dropped from the migration file directly rather than via a follow-up migration,
    since it had never shipped either.
  • Docs: docs/autoclaim.md, docs/bridge_service.md, and docs/common_config.md are fully rewritten/updated
    for all three directions and both proof-pipeline changes (architecture diagrams, sequence diagrams, config
    reference, API fields, storage tables, and the e2e test catalog) and were spot-checked against the final code
    state as part of this PR's validation sweep — including the destination-bridge-service GER gate and the
    claim-time-only leaf proof added after the initial L2→Lx docs pass.
  • API: GET /autoclaim/v1/bridges gained a source_network (uint32) response field and list-filter query
    param, and a ler (hex string, empty for L1-origin requests) response field. ler records the source LER
    observed at detection time (used to pick the covering L1 info tree leaf); the leaf→LER proof itself is never
    part of the stored request or the API response, since it is now always fetched fresh at claim time. Request IDs
    are source_network:destination_network:deposit_count (see Breaking Changes).
  • Lint: make lint still exits non-zero for the repo as a whole, but every finding is pre-existing baseline
    debt outside this plan's footprint — verified per-line via git blame that each finding predates this plan's
    work (either from before this branch existed, or from PR feat: add op-pp-2chains env and L2→L2 bridge test #1691's own commit history brought in by the
    c3e4e0f2 merge). Zero lint findings in autoclaim/**, bridgesync/**, bridgeservice/**,
    l1infotreesync/**, or the touched parts of config/**/cmd/run.go.
  • test-unit: two previously-flaky, pre-existing, out-of-scope tests (agglayer's
    TestRateLimitWrapper_* timing assertions and claimsync's docker-connectivity tests, both unrelated to
    autoclaim, flagged as such earlier in this plan's own execution log) were green on the final validation run —
    full repo make test-unit passed with zero failures.
  • Commit history: this branch's own work (autoclaim L2→Lx: S01-S17) landed as a single checkpoint commit
    (025bbb2d) followed by the PR feat: add op-pp-2chains env and L2→L2 bridge test #1691 merge (c3e4e0f2, untouched — including its own commit history — per an
    explicit decision not to rewrite anything brought in from that merge or upstream of it) and a small number of
    focused commits on top (a pre-existing env config fix, the L2→L2 test itself, a mechanical lint fix, the
    destination-bridge-service GER gate + claim-time-proof follow-up work, and a docs drift fix). 025bbb2d's "wip:"
    label was deliberately left as-is rather than split/reworded, since either change would require recreating the
    c3e4e0f2 merge commit, which was out of scope to touch.

arnaubennassar and others added 29 commits July 1, 2026 09:22
…works

Add test/e2e/envs/op-pp-2chains/ (summary.json, docker-compose.yml, config/001,
config/002, config/agglayer) and the EnvOpPP2Chains env constant.

Generalize the loader so it can expose both L2 networks without changing the
single-chain EnvOpPP behavior:
- Add optional L2B *L2Config to Env (nil for single-chain envs).
- Extract per-L2 wiring into loadL2Config; LoadEnv loads "001" -> L2 always and
  "002" -> L2B only for EnvOpPP2Chains.
- L2Config now carries its own Client, BridgeService, Keys and URLs so each L2 is
  self-contained for multi-chain tests.
- Tolerate both op-geth (op-pp) and op-reth (op-pp-2chains) execution clients via
  summaryL2Network.l2RPCExternal().
- waitForServices now waits on every L2 network rather than only the first.

checks.go: extract L2 connectivity/contracts/bridge-service checks into reusable
*For helpers, run them for L2B when present, and gate the hardcoded chain-ID
assertions by env name (op-pp: 271828/2151908, op-pp-2chains: L2A 20201 / L2B 20202).

loader_test.go: add pure-parse tests asserting op-pp-2chains parses into two L2
networks (20201, 20202) with distinct endpoints, op-pp still parses to one (2151908),
and that op-pp-2chains/summary.json exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add BridgeL2ToL2 helper in bridge_utils.go that bridges an asset from
L2A to L2B through the aggkit bridge service: send BridgeAsset on L2A
with L2B's network id as destination, poll L2A bridge service for the
deposit, wait for L1 Info Tree inclusion, then poll the DESTINATION
(L2B) bridge service for GER propagation (GetInjectedL1InfoLeaf),
fetch the claim proof from L2B, ClaimAsset on L2B, and assert the
destination balance increased.

Add TestBridgeL2ToL2 which skips when testEnv.L2B is nil (single-chain
env) and fails loudly on any bridge-service or on-chain error.

Wire AGGKIT_E2E_ENV in TestMain to select the env (default op-pp) so CI
can run the op-pp-2chains matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matrixize test-go-e2e across [op-pp, op-pp-2chains] with fail-fast:false
and AGGKIT_E2E_ENV selector. Pull both envs' images in pull-docker-images.
Bump timeout to 60m for GER-propagation headroom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r startup

InsertBlock is now idempotent: a SQLite constraint violation (block already
present) is treated as a successful no-op. This fixes the startup race where the
L2ClaimSyncer auto-start goroutine and the aggsender's
SetClaimSyncerNextRequiredBlock loop both bootstrap the same block (block 0)
concurrently, one failing with 'UNIQUE constraint failed: block.num' and leaving
the aggsender stuck forever in starting_claim_syncer_stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in both chains

OP-reth in this test env never produces a "finalized" L2 block
(eth_getBlockByNumber("finalized") always returns 0). With the previous
FinalizedBlock setting, the EVMDownloader's safe-zone check
(requestToBlock <= lastFinalizedBlock) never passed for the L2ClaimSyncer
when scanning empty blocks, causing it to loop forever at block 0.
This kept BridgeDataQuerier.GetLastProcessedBlock (which returns min of
bridge syncer and claim syncer) at 0, so the aggsender saw "no new blocks"
indefinitely and never sent a certificate — blocking the L1 info tree update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ClaimSync EVMDownloader was passed rd.GetFinalizedBlockType() as the
finalizedBlockType parameter. On OP chains (like op-pp-2chains test env),
the reorg detector uses FinalizedBlock but OP-reth never produces a
finalized block tag — eth_getBlockByNumber("finalized") returns 0
indefinitely. This caused the EVMDownloader safe-zone check
(requestToBlock <= lastFinalizedBlock) to never pass when scanning ranges
with no events, permanently stalling the L2ClaimSyncer at block 0 and
blocking the aggsender's BridgeDataQuerier (which gates on min(bridge,claim)).

Fix: pass cfg.BlockFinality instead, so the ClaimSync downloader uses its
own configured finality (LatestBlock) independent of the reorg detector's
finality (which is correctly FinalizedBlock for reorg safety).

Also revert the temporary workaround in op-pp-2chains TOML configs that
changed [ReorgDetectorL2] FinalizedBlock from "FinalizedBlock" to "LatestBlock".
The reorg detector now correctly retains FinalizedBlock semantics; only the
ClaimSync downloader's safe-zone check uses the syncer's own finality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nce assertion

- Increase healthcheck start_period for op-reth and op-node to 300s so the
  docker compose dependency chain resolves before the test timeout fires on a
  cold start.
- Add missing aggsender-validator-config.toml files, keystore files, and
  beacon spec/entrypoint overrides so all containers start correctly.
- Fix op-reth entrypoint to write the genesis hash to the shared volume so
  op-node can consume it on first boot.
- Fix testmain_test.go: extend env-load context to 15m and select env via
  AGGKIT_E2E_ENV so the matrix CI job loads the correct environment.
- Fix BridgeL2ToL2: replace incorrect native-ETH balance check with an
  ERC20 balance assertion using ComputeTokenProxyAddress + MintableERC20
  binding; update test to mint/approve the MintableERC20 token before bridging.
- Test passes end-to-end from cold start in ~250s including post-test health
  check (L1<->L2 and L2<->L1 bridge flows).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leftover .bak files from the snapshot generation process that were not
deleted in the prior commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Scope the op-pp-2chains CI job to only run TestBridgeL2ToL2 via a
  TEST_RUN Makefile variable, so existing single-chain tests (BackwardForwardLET,
  RemoveGER, etc.) don't run against the 2-chain env and fail.
- Increase bridge-deposit polling in BridgeL2ToL2 from 30x2s (60s) to
  60x5s (300s) to accommodate slower CI environments where aggkit indexing
  takes longer than on local.
- Remove two unused //nolint:gosec directives in loader_test.go flagged by
  nolintlint linter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bridge service may report L1InfoTree leaf injection slightly before
the op-reth RPC node exposes the new block via 'latest'. This caused
ClaimAsset simulation (eth_call) to revert with 'execution reverted'
because the GER wasn't visible yet in the on-chain GlobalExitRootMap.

Add a direct on-chain poll of GlobalExitRootMap(gerHash) after fetching
the claim proof in both BridgeL1ToL2 and BridgeL1ToL2WithResult, so the
claim is only attempted once the L2 RPC node confirms the GER.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GetInjectedL1InfoLeaf returns the first GER injected on L2 with index
>= the deposit's inclusion index, not necessarily the exact index. When
a parallel L2->L1 bridge updates the L1InfoTree to a higher index first,
the aggoracle may inject that higher index on L2A, skipping the lower one.

Using the original (lower) index for GetClaimProof produces a GER hash
that is absent from L2's GlobalExitRootMap, causing ClaimAsset to revert.

Fix: capture the injected leaf's L1InfoTreeIndex and use it for
GetClaimProof in both BridgeL1ToL2 and BridgeL1ToL2WithResult.
The on-chain GER confirmation poll is kept as an additional safety check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves and serves the bridge service URL for every network attached to
a rollup manager, from three sources in strict priority: config, on-chain
aggchainMetadata ("BRIDGE_SERVICE_URL"), and trustedSequencerURL + port
5577. Enumerates networks via agglayermanager and keeps the cache updated
via a lightweight FilterLogs polling loop with health-gated updates, wired
into cmd/run.go behind a config flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The package isn't consumed by anything yet — it will be wired up by a
future component instead of being started directly from cmd/run.go, so
drop the unused config plumbing and Start() call along with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…outs

The `docker compose up -d` startup chain for op-pp-2chains was exceeding
the 15-minute LoadEnv timeout on slow CI runners.  Root cause: `start_period:
300s` on all four op-reth/op-node containers creates a mandatory 600s+ serial
wait (geth→beacon→op-reth→op-node) before `docker compose up -d` returns,
leaving barely 240s of margin before the 900s context deadline.

Changes:
- op-reth-001/002, op-node-001/002: start_period 300s → 120s; interval 2s →
  10s; retries 3 → 6.  Each container now has 120s + 60s = 180s max to become
  healthy, while the worst-case serial chain drops from ~660s to ~420s.
- LoadEnv context timeout: 15min → 20min (extra buffer for slow runners).
- `make test-e2e` go test -timeout: 30m → 45m (keeps test/job ratio safe).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjDD58nJmcTg7nqgt4nYZN
Checkpoint of the in-progress autoclaim L2->L1/L2->L2 work (plan steps
S02-S17) so the S18 merge of the op-pp-2chains env branch can proceed on a
clean tree. Squash/amend as appropriate before opening the PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaD39SNqQsXv833hqWhiBE
…inREST

PR #1691's 2-chain aggkit and aggsender-validator configs predate develop's
REST-section split; the current aggkit binary fatals on the deprecated
`rest.port` field, crash-looping aggkit-001/002 and both validators. Mirror the
working single-chain op-pp config: [PublicREST] (5577) + [AdminREST] (5579).
With this the full 12-service op-pp-2chains env boots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaD39SNqQsXv833hqWhiBE
Wires autoclaim (L2ToLx detector + a network-2 claimer, static finder
URLs for both source networks) onto the op-pp-2chains env, adds
BridgeL2ToL2NoClaim and per-network aggkit stop/start/restart helpers
in envs/loader.go (generalizing the "001"-hardcoded versions), and
enables the autoclaim component for aggkit-002 in docker-compose.yml.
Includes primeL2ToL2SourceSettlement, the network-1-side analogue of
the L2->L1 test's L2-finality priming trick, needed because this env
has no op-batcher/op-proposer on either L2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaD39SNqQsXv833hqWhiBE
BridgeL2ToL1NoClaim's mine-timeout used an inline 30*time.Second
literal, tripping the mnd linter. Reuse the existing bridgeMineWait
constant already used by the sibling L1->L2/L2->L2 bridge helpers.
The Testing section only listed the three L1->L2 e2e tests, predating
S15-S19's TestAutoClaimL2ToL1AllowAll and TestAutoClaimL2ToL2AllowAll.
Document both, including the AGGKIT_E2E_ENV=op-pp-2chains selector
needed for the L2->L2 test's two-rollup environment.
…nding

golang.org/x/net and quic-go had known, fixed CVEs (GO-2026-5026,
GO-2026-4918, GO-2026-5676); bump both. github.com/pion/dtls/v2 has a
nonce-reuse CVE (GO-2026-4479) with no upstream fix and is only reachable
from test helpers/dev tooling, so replace govulncheck-action with a
scripted run that fails on anything outside an explicit ignore list.
GitHub's mermaid renderer failed to parse two diagrams: an unquoted
flowchart edge label containing parentheses (GetURL(source)) is
misread as node-shape syntax, and a semicolon inside a sequence
diagram Note is treated as a statement terminator, truncating it
mid-sentence.
…clusions

gosec/lll/gocritic are already excluded project-wide for test files and
specific rules (G115, ifElseChain) in .golangci.yml, so the explicit
per-line/per-file //nolint comments added defensively are dead and trip
nolintlint's unused-directive check.
… fetcher

- Pin `go install golang.org/x/vuln/cmd/govulncheck` to v1.5.0 instead of
  @latest: SonarCloud's S8545 rule flags unpredictable dependency versions,
  which alone dropped the new-code security rating below the quality gate.
- Add unit tests for l2_to_lx_fetcher.go (ServiceFetcher, toClaimCandidate,
  toTreeProof, parseAmount, parseHexBytes), which shipped with 0% coverage
  and was the largest contributor to the new-code coverage gate failure
  (new_coverage 74.2% vs required 80%).
…g env

TestAutoClaimL2ToL2AllowAll called envs.LoadEnv(EnvOpPP2Chains) directly,
bringing up a second docker-compose stack alongside the singleton env
TestMain already started for the "op-pp" CI matrix leg. Both envs bind the
same host ports (8545, 8546, 8551, ...), so under the op-pp leg (which runs
the whole package with no -run filter) this reliably failed with "port is
already allocated".

Follow TestBridgeL2ToL2's existing pattern instead: use the shared testEnv
TestMain already loaded (selected via AGGKIT_E2E_ENV) and skip when
testEnv.L2B is nil. Also add the test to the op-pp-2chains matrix leg's
-run filter so it actually executes against the 2-chain env.
…sed as a shell pipe

Makefile passed $(TEST_RUN) unquoted to -run, so TEST_RUN="A|B" made the shell
treat "|" as a pipe: `go test ... -run A | B`, running B as a command (which
doesn't exist) instead of passing the whole alternation to -run.
SonarCloud's S8545 rule still flagged @v1.5.0 as an unpredictable
dependency version (tags aren't treated as immutable). Pin to the exact
commit the v1.5.0 tag points to instead.
@arnaubennassar

Copy link
Copy Markdown
Collaborator Author

@claude

@claude

claude Bot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @arnaubennassar's task in 2m 13s —— View job


Review Complete ✅

I've completed a thorough review of PR #1705. Here's my assessment:


🚀 What's New

This PR extends Auto Claim to support L2→L1 and L2→L2 bridge claiming in addition to the existing L1→L2 direction. Key additions include:

  • New L2ToLx bridge detector that watches l1infotreesync for rollup LER updates and fetches bridge exits from source rollups' bridge services
  • New RollupPreparer for building rollup-origin claim proofs with automatic staleness refresh
  • New bridge service endpoint GET /bridge/v1/claim-candidates that serves bridges with Merkle proofs against a requested LER
  • Support for L1-destination claimers (NetworkID = 0)
  • bridgeservicefinder integration for resolving source rollup bridge service URLs
  • source_network-based request keying (replacing origin_network which was ambiguous for wrapped tokens)

🐛 Bug Fixes

None - this is a pure feature addition.

📋 Config Updates

[AutoClaim.L2ToLxBridgeDetector]
Enabled = true
StartL1Block = 0            # 0 = full history
PollInterval = "3s"
RetryAfterErrorPeriod = "1s"
MaxRetryAttemptsAfterError = -1

[AutoClaim.BridgeServiceFinder]
RollupManagerAddr = "0x..."  # Required when L2ToLxBridgeDetector enabled
PollInterval = "30s"         # Default value

# L1-destination claimer (new capability):
[[AutoClaim.Claimers]]
Enabled = true
NetworkID = 0  # 0 now valid for L1 destination
BridgeAddr = "0x..."  # L1 bridge contract
URLRPC = "http://l1-rpc:8545"
# No BlockFinality/InitialBlockNum (L1 has no l2gersync)

⚠️ Breaking Changes

  • Request ID format: Changed from origin_network:destination_network:deposit_count to source_network:destination_network:deposit_count. For L1→L2 requests these are numerically identical (source=0, origin=0 for L1 tokens), but semantically corrected.
  • Config: [AutoClaim.L2ToLxBridgeDetector] is now a real detector (was disabled placeholder). New [AutoClaim.BridgeServiceFinder] section required when enabled.
  • API: GET /autoclaim/v1/bridges response adds source_network and ler fields; request IDs changed format (see above).

📊 Review Summary

Code Quality: Excellent

  • Well-structured with clear separation of concerns
  • Comprehensive error handling throughout
  • Extensive use of interfaces for testability
  • Clean, idiomatic Go code following repo conventions

Test Coverage: Outstanding

  • 83.4% coverage on new code (SonarCloud)
  • 4 new e2e tests covering all three bridge directions
  • Unit tests for every new package/function
  • All tests passing (make test-unit, e2e runs documented in PR)

Security: Strong

  • Proper SQL injection protection via parameterized queries
  • Input validation with DoS protection (max 5 network IDs limit)
  • Merkle proof verification before persistence
  • No obvious vulnerabilities found

Documentation: Comprehensive

  • Detailed PR description with architecture, testing, migration notes
  • Updated docs/autoclaim.md, docs/bridge_service.md
  • Inline code comments for complex logic
  • Swagger API docs updated

🔴 Required Issues

1. Migration Safety Assumption

File: autoclaim/storage/migrations/autoclaim0002.sql:114

The migration comment states:

-- autoclaim0001 ships in no release/tag (verified in the plan, §4bis #6)

Issue: This is a time-bomb 💣. If someone accidentally backports or cherry-picks autoclaim0001 to a release branch before this PR merges, the migration will corrupt data by blindly assuming all rows have source_network=0.

Recommendation: Add a runtime check in the migration:

-- Verify assumption: all existing rows should be L1-origin
SELECT CASE WHEN EXISTS (
  SELECT 1 FROM autoclaim_request WHERE origin_network != 0
) THEN RAISE(ABORT, 'autoclaim0002 migration cannot run: found non-L1-origin requests')
END;

This makes the assumption explicit and enforced, preventing silent data corruption.


🟡 Suggested Improvements

2. SQL Query Inefficiency in Pagination

File: bridgesync/processor.go:606-618

The GetBridgesInDepositRange function calls GetTotalNumberOfRecordsWithParams on every page, which executes a full COUNT(*) query. For large tables this becomes O(n) per page request.

Current:

bridgesCount, err := p.GetTotalNumberOfRecordsWithParams(ctx, bridgeTableName, whereClause, whereArgs)
// ... executed on every page

Suggestion: Return total count only on first page, or use window functions:

SELECT *, COUNT(*) OVER() as total_count FROM bridge WHERE ...

Impact: Medium - affects performance at scale when paginating through large result sets.

3. Magic Number for Leaf Selection Bound

File: autoclaim/proof/rollup_preparer.go:24

const maxLeafSelectionAdvances = 256

Issue: Hard-coded constant with no justification. What happens if a rollup genuinely needs 257 advances due to empty leaves or chain conditions?

Suggestion: Make this configurable via ClaimerConfig with a sensible default, or document why 256 is sufficient based on L1 info tree insertion frequency.

4. Potential Race in Cursor Advancement

File: autoclaim/bridgedetector/l2_to_lx.go (around cursor advancement logic)

The detector advances the LER cursor after successfully fetching all candidates for a source. If the process crashes between fetching candidates and advancing the cursor, those candidates will be re-fetched on restart.

Current behavior: Idempotent enqueue prevents duplicates, so this is safe but inefficient.

Suggestion: Consider batching cursor advances with candidate enqueues in a transaction, or document this as intentional "at-least-once" semantics.

5. Missing Rate Limiting on Bridge Service Calls

File: autoclaim/bridgedetector/l2_to_lx_fetcher.go:70-103

The fetcher makes unbounded HTTP calls to source bridge services with no client-side rate limiting or retry backoff.

Risk: If a source bridge service is slow/flaky, this could:

  • Spam failed requests
  • Exhaust connection pools
  • Cause the detector to fall behind

Suggestion: Add rate limiting and exponential backoff in ServiceFetcher, or use the existing RetryAfterErrorPeriod config more defensively.

6. Error Handling: Swallowed Context in Defer

File: bridgesync/processor.go:633-637

defer func() {
    if cerr := rows.Close(); cerr != nil {
        p.log.Errorf("error closing rows: %v", cerr)
    }
}()

Issue: If the function returns early due to an error, the deferred rows.Close() error is only logged but not surfaced. In production this can leak connections.

Suggestion: Standard pattern:

defer func() {
    if cerr := rows.Close(); cerr != nil && err == nil {
        err = fmt.Errorf("close rows: %w", cerr)
    }
}()

🟢 Optional Nits

7. Inconsistent Naming: L2ToLx vs L2ToL1 and L2ToL2

The detector is named L2ToLx (generic), but configs/docs sometimes refer to "L2→L1" and "L2→L2" separately. Consider documenting why "Lx" (any layer) vs splitting into two detectors.

8. Test Timeout Hardcoded

File: test/e2e/autoclaim_test.go (e2e tests use -timeout 30m)

Consider extracting this to an env var for CI flexibility.

9. Comment Typo

File: l1infotreesync/processor_verifybatches.go:23

// If ExitRoot is zero if the leaf doesnt exists doesnt change the root of tree.

Should be: "If ExitRoot is zero, the leaf doesn't exist and doesn't change the root of the tree."


🎯 Overall Assessment

This is high-quality, production-ready code with one critical migration safety issue that should be addressed before merge.

Strengths:

  • Excellent test coverage (83.4% + comprehensive e2e)
  • Clean architecture with proper interfaces and DI
  • Thorough error handling and input validation
  • Well-documented with updated guides

Merge recommendation: ✅ Approve after addressing Required issue #1

The migration assumption (#1) is the only blocking concern - everything else is optimization or polish. Once that's fixed with a safety check, this PR is ready to land.


✅ Action Items for Author

  1. 🔴 [Required] Add migration safety check in autoclaim0002.sql (issue Unify L1 syncers #1)
  2. 🟡 Consider pagination optimization for GetBridgesInDepositRange (issue Split L1 / L2 responsibilities for lastgersync #2)
  3. 🟡 Document or make configurable maxLeafSelectionAdvances (issue Add UTs for lastgersync #3)

arnaubennassar and others added 2 commits July 8, 2026 19:00
Two architectural improvements to Auto Claim:

1. Replace the per-claimer l2gersync instance with a call to the
   destination network's own aggkit bridge service for the GER-injection
   gate, discovered via bridgeservicefinder.Finder. The proof.L2GERSyncer
   seam now returns the covering injected leaf index directly (+ an
   ErrGERNotInjected sentinel), the new BridgeServiceGERGate calls the
   existing GET /bridge/v1/injected-l1-info-leaf endpoint, and the runtime
   no longer builds per-claimer SQLite DBs, L2 reorg detectors, or L2
   clients. Applies to all directions incl. L1->L2. The injected-l1-info-leaf
   endpoint now returns 404 (not 500) when the GER is not injected yet, which
   the gate treats as "not ready" without burning the claim retry budget.

2. Fetch the leaf->LER Merkle proof only at claim time. /bridge/v1/claim-candidates
   no longer computes/returns per-bridge proofs, the L2ToLx detector no longer
   stores a leaf proof, and RollupPreparer always refreshes the proof from the
   source bridge service at claim time. Drops the leaf_proof_json column (kept
   ler/verify_block_num).

BREAKING: L1->L2 auto claiming now requires a configured
[AutoClaim.BridgeServiceFinder] and reachable destination bridge services;
[[AutoClaim.Claimers]] no longer accepts BlockFinality/InitialBlockNum.

Regenerated swagger + mocks; updated docs and the PR description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment

Addresses the only actionable nit from the automated review on PR #1705
(the remaining findings are false positives, by-design, or
convention-consistent — documented in the PR reply).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arnaubennassar

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review. I went through each finding against the actual code. Disposition below; commit 421ba23 addresses the one actionable item.

🔴 #1 Migration safety — not applied (false positive, suggested fix is incorrect)

The re-key '0:' || destination_network || ':' || deposit_count matches DeriveRequestKey(source, dest, deposit) (autoclaim/types/types.go:324), and source = 0 is correct by construction: autoclaim0001 had only the L1→L2 detector, whose source is always L1 (network 0). There is no source_network column in the autoclaim0001 schema to verify against — so the assumption cannot be checked at migration time.

The suggested guard WHERE origin_network != 0 is actively wrong: origin_network is the token origin network, which is non-zero for wrapped tokens in perfectly valid L1→L2 claims. That check would falsely ABORT legitimate migrations. The backport concern is a process risk, not a code defect; the existing comment already documents the invariant.

🟡 #2 COUNT(*) per page — by design

GetBridgesInDepositRange needs the total for offset validation (calculateOffset) and to return the total count to the API caller. This matches the existing paginated-query pattern used throughout processor.go.

🟡 #3 maxLeafSelectionAdvances = 256 — already justified

The constant is documented at rollup_preparer.go:20-23 (common case advances zero times; the bound only guards a pathological run). Making it configurable is scope creep with no demonstrated need.

🟡 #4 Cursor advancement — already intentional

As the finding itself notes, idempotent enqueue makes this safe (at-least-once). This is the intended semantics.

🟡 #5 Rate limiting — out of scope

The fetcher already honors RetryAfterErrorPeriod / MaxRetryAttemptsAfterError. Adding a rate limiter is a separate enhancement, not a defect in this PR.

🟡 #6 rows.Close() error surfacing — convention-consistent

Logging (not surfacing) the deferred rows.Close() error is the uniform repo convention — same pattern at processor.go:483,537,634,725,1525,1558,1625. The cerr != nil && err == nil pattern appears nowhere in the codebase. rows.Close() still runs via defer, so there is no connection leak. Changing this one site would introduce an inconsistency.

🟢 #7 L2ToLx naming — intentional

One detector handles both L2→L1 and L2→L2 (destination is the only difference); Lx reflects that single code path. Docs describe the user-facing directions separately for clarity.

🟢 #8 e2e timeout — out of scope

🟢 #9 Comment typo — ✅ fixed in 421ba23

l1infotreesync/processor_verifybatches.go:23 grammar corrected.

Net: 8 of 9 findings are non-defects (by-design, already-documented, convention-consistent, or based on an incorrect premise); the typo is fixed.

@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@arnaubennassar arnaubennassar self-assigned this Jul 9, 2026
@joanestebanr
joanestebanr force-pushed the feat/bridge-service-finder branch from 4e9a694 to d94e5f5 Compare July 17, 2026 07:49
Base automatically changed from feat/bridge-service-finder to develop July 17, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant