Skip to content

fix(autoclaim): don't let a permanently disabled source pin the l2-to-lx block cursor - #1882

Merged
arnaubennassar merged 2 commits into
developfrom
fix/l2-to-lx-disabled-source-cursor-deadlock
Sep 30, 2026
Merged

arnaubennassar merged 2 commits into
developfrom
fix/l2-to-lx-disabled-source-cursor-deadlock

Conversation

@arnaubennassar

@arnaubennassar arnaubennassar commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

🔄 Changes Summary

  • Fix: a source network permanently excluded from bridge service resolution no longer pins the L2ToLx detector's shared L1 block-window cursor, which previously stopped all L2-origin claim discovery.

autoclaim/bridgedetector/l2_to_lx.go keeps one block-window cursor (l2-to-lx) shared by every source network. processSource returned sourceRetryLater for any fetcher.GetURL error, and PollOnce then held that shared cursor just before the earliest skipped source's verify row — or, when that row sat at the very start of the window, return result, nil without saving the cursor at all, re-scanning the identical window on the next poll.

bridgeservicefinder.GetURL returns ErrNetworkDisabled for every network listed in Config.IgnoreNetworkIDs. That error is a deliberate, permanent exclusion — it can never clear. So a single ignored network with a VerifyBatches row anywhere in the scanned range wedged the shared cursor forever, and no L2-origin claim request could ever be created for any source.

Observed in a deployment whose ignore list covers most registered rollups: over 8 hours the only skip source line emitted named an ignored network, and the request store contained L1-origin requests exclusively — not one L2-origin request.

The fix: a permanently excluded source is sourceUpToDate ("nothing to do") instead of sourceRetryLater. It no longer contributes to retryBlock and no longer holds the window back. Its own (source, destination) LER cursors are still left untouched, so when the network is later un-ignored, its next verify row is fetched from the old pair cursor and covers everything bridged in between. That recovery does depend on the source publishing another verify row: the shared window has already moved past the row seen while the source was excluded and only rewinds by overlapBlocks (default 1), so it never returns to it. To un-ignore a source that has stopped producing verified batches, reset the l2-to-lx block-window cursor so its old verify row falls inside the scanned range again. The trade is deliberate — the old behaviour bought that re-observability at the cost of deadlocking every source. A genuinely retryable GetURL failure (service not yet announced, or unhealthy) keeps its existing hold-the-window behaviour, unchanged.

Two smaller points:

  • The permanence distinction is expressed in the fetcher contract rather than read out of bridgeservicefinder by the detector. ClaimCandidatesFetcher gains a detector-local ErrSourceDisabled, the permanent counterpart of ErrURLNotFound; ServiceFetcher.GetURL — the designated translation point — maps the finder's disabled error to it, checked before the not-found branch that error wraps, and still wrapping ErrURLNotFound since an excluded source is also a source with no URL. processSource branches on the local sentinel and no longer imports bridgeservicefinder, and both sentinels are documented on the GetURL contract: the whole bug was a permanence distinction the contract never expressed. A test pins the mapping, so a future refactor cannot silently reintroduce the deadlock, and any non-finder-backed fetcher can now express the same thing.
  • An excluded source is now reported once per source rather than on every poll. The exclusion never clears and the detector polls for the lifetime of the process, so the repetition carried no information.

The doc comments on sourceOutcome, PollOnce and processSource said "transient" where the code accepted any error; they are updated to match the new semantics.

⚠️ Breaking Changes

  • None. No config, API, CLI or storage change.

📋 Config Updates

  • None.

🔌 API Updates

  • None.

✅ Testing

  • 🤖 Automatic: 6 new tests in autoclaim/bridgedetector:
    • TestL2ToLxDisabledSourceDoesNotHoldBlockCursor — an excluded source does not hold the cursor back; the window advances past its verify row.
    • TestL2ToLxTransientURLErrorStillHoldsBlockCursor — a retryable URL miss still holds the cursor (guards against regressing the existing behaviour).
    • TestL2ToLxDisabledSourceDoesNotStarveHealthySource — mixed window: one excluded source plus one healthy source at a later block; the healthy source processes and the cursor advances.
    • TestL2ToLxDisabledSourceAtWindowStartDoesNotPinCursor — the production regression: an excluded source's verify row at the very start of the window must not take the "return without saving the cursor" path.
    • TestL2ToLxDisabledSourceIsReportedOncePerSource — the report is emitted once, not per poll.
    • TestServiceFetcherGetURL/network_disabled_maps_to_the_permanent_sentinel — pins that the finder's disabled error arrives at the detector as ErrSourceDisabled, never as a plain retryable ErrURLNotFound, including the branch ordering that makes it so.
  • Confirmed to be genuine regression tests. With the test files in place and l2_to_lx.go reverted to its pre-fix state, 4 of the 5 behavioural tests fail:
    --- FAIL: TestL2ToLxDisabledSourceDoesNotHoldBlockCursor        (cursor 0x13 = 19, want 0x31 = 49)
    --- FAIL: TestL2ToLxDisabledSourceDoesNotStarveHealthySource    (cursor 0x13 = 19, want 0x31 = 49)
    --- FAIL: TestL2ToLxDisabledSourceAtWindowStartDoesNotPinCursor (CursorAdvanced: should be true)
    --- FAIL: TestL2ToLxDisabledSourceIsReportedOncePerSource       (mock: Unexpected Method Call)
    --- PASS: TestL2ToLxTransientURLErrorStillHoldsBlockCursor      (behaviour intentionally unchanged)
    --- PASS: TestServiceFetcherGetURL                              (pins existing behaviour by design)
    
    All pass with the fix applied, alongside the pre-existing retry tests (TestL2ToLxNotSyncedSkipsSourceWithoutAdvancingCursor, TestL2ToLxRetriesSkippedSourceOnLaterPollWithoutNewLER, TestL2ToLxRetrySkipAtWindowStartKeepsStoredCursor).
  • go test ./autoclaim/... ./bridgeservicefinder/... — all packages ok.
  • golangci-lint run --timeout 5m — 0 issues.
  • 🖱️ Manual: none required.

🐞 Issues

  • No tracking issue.

🔗 Related PRs

📝 Notes

  • Design decision: the excluded source's per-pair LER cursors are deliberately not advanced. The block window is only the trigger for re-examining a source; the per-(source, destination) LER cursor is what bounds the actual candidate fetch. Leaving it untouched means that if the operator later un-ignores the network, its next verify row is fetched from the old pair cursor and still covers every bridge exit made while it was excluded — even though the block window has long since moved past. The converse is the operational caveat above: a re-enabled source that never publishes another verify row is never re-examined, because the window does not rewind to the row it already passed. Resetting the block-window cursor is the recovery for that case.
  • The once-per-source reporting is a plain map[uint32]struct{} on the detector, touched only from the single poll goroutine — deliberately not a general log-dedup mechanism.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T

…-lx cursor

L2ToLx keeps one shared L1 block-window cursor across every source network.
processSource returned sourceRetryLater for any fetcher.GetURL error, and PollOnce
then held that shared cursor just before the earliest skipped source's verify row
-- or, when that row sat at the very start of the window, returned without saving
the cursor at all, re-scanning the identical window on the next poll.

bridgeservicefinder.GetURL returns ErrNetworkDisabled for every network listed in
Config.IgnoreNetworkIDs. That error is a deliberate, permanent exclusion: it can
never clear. So one ignored network with a VerifyBatches row anywhere in the
scanned range wedged the shared cursor forever and no L2-origin claim request
could be created for any source. Observed in a deployment whose ignore list covers
most registered rollups: the only "skip source" line ever emitted named an ignored
network, and the request store held L1-origin requests exclusively.

Treat a permanently excluded source as "nothing to do" (sourceUpToDate) instead of
"retry later": it no longer contributes to retryBlock and no longer holds the
window back. Its own (source, destination) LER cursors are still left untouched, so
removing the network from the ignore list later loses nothing -- its next verify row
is fetched from the old pair cursor. A genuinely retryable GetURL failure (service
not yet announced or unhealthy) keeps its existing hold-the-window behaviour.

ErrNetworkDisabled wraps ErrURLNotFound, so ServiceFetcher.GetURL re-wraps it
through the not-found branch; the sentinel survives that (double %w) and stays
detectable with errors.Is. A test pins that, so a refactor of the mapping cannot
silently reintroduce the deadlock.

Also report an excluded source once per source instead of on every poll: the
exclusion never clears and the detector polls for the process lifetime, so the
repetition carried no information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T
@arnaubennassar

Copy link
Copy Markdown
Collaborator Author

@claude

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

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


Review: PR #1882 — disabled source no longer pins the l2-to-lx block cursor

  • Gather context (diff, related files)
  • Analyze the fix in l2_to_lx.go
  • Review tests
  • Post review + summary template

Verdict

The diagnosis is correct and the fix is minimal and targeted. bridgeservicefinder.ErrNetworkDisabled (bridgeservicefinder/interfaces.go:44) is built by wrapping ErrURLNotFound, so ServiceFetcher.GetURL (autoclaim/bridgedetector/l2_to_lx_fetcher.go:58) funnels it into the same detector-local ErrURLNotFound, and processSource previously returned sourceRetryLater for it. With a single shared retryBlock in PollOnce (l2_to_lx.go:403-443) and retryBlock <= fromBlock short-circuiting to return result, nil, a permanently ignored network with a verify row in the window really does wedge every source forever. Verified by reading the code, not just the PR body.

The decision to leave the per-(source, destination) LER cursors untouched is the right one, and the regression tests are genuine (the reported pre-fix failures match what the code paths would do).

I could not run go test / golangci-lint here — the sandbox denied go build and go test. Findings below are from static reading only.


🟡 Suggested

1. SkippedSourceCount's doc comment wasn't updated with the others — autoclaim/bridgedetector/l2_to_lx.go:199-202

The PR carefully updated the doc comments on sourceOutcome, PollOnce and processSource, but this one still reads:

SkippedSourceCount is the number of sources skipped this poll: nothing new for any pair, or at least one pair's fetch group must be retried (finder miss or not synced yet). A source whose groups partially succeeded counts as skipped, since its block-window position is held back.

A permanently excluded source now lands here (via sourceUpToDate at l2_to_lx.go:421) and is neither "nothing new for any pair" — NewLERSourceCount was already incremented at line 500 — nor held back. It's the one remaining place that still describes the old semantics.

Fix this →

2. The "re-enabling loses nothing" guarantee holds only if the source publishes another verify row

The PR body says removing the network from the ignore list later "loses nothing". That's true for the LER cursor, but a source only enters latestBySource if it has a VerifyBatches row inside the current block window (l2_to_lx.go:390-396), and nextFromBlock's overlap (l2_to_lx.go:853-861) is a small fixed number of blocks, not a rewind. So after the fix:

  • the window advances past the excluded source's verify row and never comes back to it;
  • if the network is later un-ignored (a config change, so a restart) but has stopped producing verified batches, it is never re-examined and its pending exits are never discovered.

That's exactly the shape of network most likely to be on an ignore list — a halted or broken rollup. The old buggy behaviour accidentally preserved re-observability at the cost of the deadlock; this trade is clearly the right one, but it is a real trade and the guarantee as worded is stronger than the code delivers. Worth either narrowing the claim in the comment at l2_to_lx.go:511-513 or adding the operational note (to un-ignore a dormant network, reset the l2-to-lx block cursor so its old verify row is re-scanned).

3. The detector core now reaches into bridgeservicefinder for a sentinel the fetcher abstraction was built to hide — l2_to_lx.go:9, l2_to_lx.go:504

ClaimCandidatesFetcher exists so "unit tests can mock the remote bridge service" and so the detector talks in detector-local errors; ServiceFetcher.GetURL is the designated mapping point. After this change the detector branches on a finder-package sentinel that only leaks through by accident of ErrNetworkDisabled wrapping ErrURLNotFound — which is why the new fetcher test is needed to pin it. A detector-local ErrSourceDisabled (or ErrURLPermanentlyUnavailable), mapped in ServiceFetcher.GetURL alongside the existing ErrURLNotFound mapping, would keep the permanence signal inside the interface contract and let any non-finder-backed fetcher express it.

Relatedly, the interface doc at l2_to_lx.go:97-99 still documents only ErrURLNotFound → "skip this source this round", with no mention that some URL failures are now permanent and treated differently. Since the whole bug was a permanence distinction the contract didn't express, that contract is the natural place to write it down.

Fix this →


🟢 Optional

4. The once-per-source log matcher is loose — l2_to_lx_test.go:~490

logger.EXPECT().Infof(mock.Anything, mock.Anything).Once() matches any Infof with exactly one variadic arg, so the test would also be satisfied by an unrelated future one-arg log line. mock.MatchedBy(func(f string) bool { return strings.Contains(f, "is disabled") }) for the format, plus asserting the source ID, would pin what it means to pin.

5. logDisabledSourceOnce's nil-map guard is unreachable — l2_to_lx.go:879-881

NewL2ToLx always initialises the map and options only mutate an already-constructed detector, so the lazy init never fires. Harmless, but it slightly obscures that the map has exactly one owner. Worth noting too that the "only ever touched from the single poll goroutine" invariant (l2_to_lx.go:233-234) is doc-only — PollOnce is exported, and Start happens to be its only production caller. Fine as-is given the current call graph; just don't let the map grow into something a second caller would touch.

6. NewLERSourceCount keeps incrementing for an excluded source on every poll — l2_to_lx.go:500

Its pair cursor never advances, so it re-counts as "has new LERs" forever. Nothing consumes this field outside tests today, so it's cosmetic — but if it ever becomes a metric, an ignored network will be a permanent non-zero floor.


Security / performance

No security implications: no new external input, no new network call, no change to what is claimed or by whom — the only behavioural delta is which block cursor value gets persisted. Performance strictly improves: the pathological case this fixes was re-scanning an identical block window on every poll indefinitely.

Test coverage

Good. The five behavioural tests cover the cursor advance, the preserved retryable-hold behaviour (the important companion — easy to over-fix this and lose it), the mixed healthy/excluded window, the window-start regression that was actually observed in production, and the log-once behaviour. TestServiceFetcherGetURL/network_disabled_sentinel_survives_the_not-found_wrapping is a well-placed guard given how fragile the double-%w path is; note that finding 3, if applied, would make that guard structural rather than incidental.


🚀 What's New

A source network permanently excluded from bridge service resolution (bridgeservicefinder.Config.IgnoreNetworkIDs) is now treated as "nothing to do" rather than "retry later" by the L2ToLx bridge detector, so it no longer holds back the block-window cursor that all source networks share. Excluded sources are reported once per source instead of once per poll, and doc comments on sourceOutcome, PollOnce and processSource are updated to describe retryable-vs-permanent skips.

🐛 Bug Fixes

  • A single ignored network with a VerifyBatches row in the scanned L1 range permanently pinned the shared l2-to-lx block-window cursor, stopping all L2-origin claim discovery for every source network. Observed in a deployment with a large ignore list: over 8 hours, zero L2-origin claim requests were created. Genuinely retryable URL failures (service not yet announced, or unhealthy) keep their existing hold-the-window behaviour.

📋 Config Updates

None.

⚠️ Breaking Changes

None. No config, API, CLI or storage change.


• fix/l2-to-lx-disabled-source-cursor-deadlock

…in the fetcher contract

Review follow-up. The fix itself is unchanged: a permanently excluded source still
counts as "nothing to do" and still lets the shared block-window cursor advance, and a
genuinely retryable GetURL failure still holds the window just before its verify row.

The detector was reaching into bridgeservicefinder for the sentinel that told the two
apart, which only worked because ErrNetworkDisabled happens to wrap ErrURLNotFound.
ClaimCandidatesFetcher exists so the detector talks in detector-local errors and so a
non-finder-backed fetcher can express the same thing, so:

- add a detector-local ErrSourceDisabled, the permanent counterpart of ErrURLNotFound;
- map the finder's disabled error to it in ServiceFetcher.GetURL -- the designated
  translation point -- checked before the not-found branch it wraps, and still wrapping
  ErrURLNotFound since an excluded source is also a source with no URL;
- branch processSource on the local sentinel and drop the bridgeservicefinder import;
- document both sentinels, and what the detector does with each, on the
  ClaimCandidatesFetcher.GetURL contract. The whole bug was a permanence distinction the
  contract never expressed, so it belongs written down there.

Also from the review:

- SkippedSourceCount's doc still claimed every skipped source has its block-window
  position held back; a permanently excluded source is counted there and does not.
- narrow the re-enabling note: leaving the pair LER cursors untouched covers everything
  bridged in between, but recovery needs the source to publish another verify row, since
  the window only rewinds by overlapBlocks and never returns to the row observed while
  the source was excluded. Records the operational consequence: to re-enable a source
  that has stopped producing verified batches, reset the block-window cursor.
- tighten the once-per-source log expectation, which matched any single-argument Infof,
  to the format text plus the source id.
- drop the unreachable lazy init in logDisabledSourceOnce; the constructor always builds
  the map, and the guard only obscured that it has one owner.

NewLERSourceCount still counts an excluded source on every poll. Left as is: the field
is defined as "at least one pair whose cursor differs from the source's newest LER",
which is true for it, and equally true for the retryable-skip path, so moving the
increment would make the two skip paths disagree for no consumer's benefit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T
@arnaubennassar

Copy link
Copy Markdown
Collaborator Author

Automated review addressed in 66b8848. The fix's behaviour is unchanged — a permanently excluded source still lets the shared block-window cursor advance, and a genuinely retryable GetURL failure still holds it just before its verify row (TestL2ToLxTransientURLErrorStillHoldsBlockCursor still passes).

Applied:

  1. SkippedSourceCount doc — it claimed every skipped source has its block-window position held back; a permanently excluded source is counted there and does not. Reworded to name all three skip reasons and say which one holds the window.
  2. "re-enabling loses nothing" — narrowed in the PR body and in the code comment. overlapBlocks defaults to 1, so the window rewinds a single block and never returns to a row it has passed: a source that is un-ignored but has stopped producing verified batches is never re-examined. The trade is still the right one, so the behaviour stands; the recovery (reset the l2-to-lx block cursor) is now written down where an operator will find it.
  3. Detector-local sentinel — agreed, and the most useful item. Added ErrSourceDisabled, mapped in ServiceFetcher.GetURL before the not-found branch it wraps and still wrapping ErrURLNotFound; processSource branches on the local sentinel and l2_to_lx.go no longer imports bridgeservicefinder. Both sentinels, and what the detector does with each, are now on the ClaimCandidatesFetcher.GetURL contract — the permanence distinction the contract failed to express is what the bug was. The fetcher test is kept and rewritten to pin the mapping and its branch ordering, so it guards structure rather than an accident.
  4. Loose log matcher — tightened to the format text plus the source id. Verified it bites: flipping the matched substring makes the test fail.
  5. Unreachable nil-map guard — removed.

Declined:

  1. NewLERSourceCount for an excluded source — the increment does happen before the disabled branch, so the observation is correct, but the field is defined as "at least one pair whose cursor differs from the source's newest LER". That is true for an excluded source, and equally true for the retryable-skip path the review does not object to — a transiently skipped source re-counts on every poll too. Moving the increment would make the two skip paths disagree about the same field, for a counter nothing consumes outside tests. Left as is, and recorded in the commit message.

One correction: the review's framing of the fetcher test as "incidental" was right as a description of the old code but the double-%w survival was never the mechanism the detector should have relied on — finding 3 is the actual fix for that, not a polish on top of it.

go build ./... clean, go test ./autoclaim/... ./bridgeservicefinder/... all ok, golangci-lint run --timeout 5m → 0 issues.

@krlosMata
krlosMata self-requested a review September 30, 2026 14:35

@krlosMata krlosMata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😸

@arnaubennassar
arnaubennassar merged commit 4dfdeda into develop Sep 30, 2026
47 of 48 checks passed
@arnaubennassar
arnaubennassar deleted the fix/l2-to-lx-disabled-source-cursor-deadlock branch September 30, 2026 15:12
arnaubennassar added a commit that referenced this pull request Oct 1, 2026
This PR collects a small set of operational fixes found while running
`autoclaim` in a production
deployment. It started as a single fix for `EthTxManager` defaults and
has grown a second,
unrelated fix (start-block defaulting) found during the same operating
window — each small enough
on its own not to warrant a separate PR, but included here explicitly so
reviewers know why this
is multi-part.

## 1. Apply EthTxManager defaults to claimers

- Add `Config.ApplyDefaults()` in `autoclaim/config/config.go`, called
once in `config/config.go` right after config unmarshalling and before
`AutoClaim.Validate()`. It fills each `[[AutoClaim.Claimers]]` entry's
`EthTxManager` fields that were left at their Go zero value with
aggkit's own established `AggOracle.EVMSender.EthTxManager` opinion
(`config/default.go`), and never touches a value the operator explicitly
set.
- Fix a key mismatch in `config/default.go`'s
`[AggOracle.EVMSender.EthTxManager]` block: it rendered TOML keys
`GetReceiptMaxTime` / `GetReceiptWaitInterval`, but
`ethtxmanager.Config`'s mapstructure tags are `WaitReceiptMaxTime` /
`WaitReceiptCheckInterval`
([`zkevm-ethtx-manager@v0.2.18/ethtxmanager/config.go:19,22`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/config.go#L19-L22)),
so those two AggOracle defaults were silently never binding. Verified
against the vendored module source before including it.

### Why

TOML array-of-tables elements (like `[[AutoClaim.Claimers]]`) get no
per-element defaults from the config loader, and `config/default.go`'s
`[AutoClaim]` section stops at `BridgeServiceFinder` — it defines no
`[[AutoClaim.Claimers]]` at all. So any claimer field an operator does
not fully spell out starts from its Go zero value.

Two of those zero values are actively dangerous in
`github.com/0xPolygon/zkevm-ethtx-manager@v0.2.18/ethtxmanager`:
- `FrequencyToMonitorTxs=0` → `time.After(0)` fires immediately,
spinning the tx-monitor loop with no delay
([`ethtxmanager.go:485`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/ethtxmanager.go#L485)).
- `GasPriceMarginFactor=0` → the suggested gas price is multiplied by
zero, so every transaction is rejected as `transaction underpriced`
([`ethtxmanager.go:989-991`](https://github.com/0xPolygon/zkevm-ethtx-manager/blob/v0.2.18/ethtxmanager/ethtxmanager.go#L989-L991)).

Both were hit back-to-back in a production deployment: a claimer without
an explicit `EthTxManager` block first submitted underpriced
transactions, then — once that surfaced and `GasPriceMarginFactor` alone
was patched at the deploy-config layer — the *same* already-wedged
monitored transaction kept retrying in the busy-loop, because re-pricing
an existing transaction is only ever applied to a transaction that
reached `Sent` status, and an underpriced-at-creation transaction never
gets there. The result was **~1,500-1,600 log lines/second sustained for
roughly 4 days, on the order of 100 million log lines**, from a pod
Kubernetes reported as perfectly healthy the entire time (no crash, no
restart, probes green) — nothing in standard pod-health monitoring would
have caught it.

### What is defaulted, and why those values

`Config.ApplyDefaults()` mirrors aggkit's own
`AggOracle.EVMSender.EthTxManager` values (`config/default.go`), so
every `EthTxManager` instance in the binary behaves the same way absent
explicit operator config:

| Field | Default | Rationale |
|---|---|---|
| `FrequencyToMonitorTxs` | `1s` | matches AggOracle; prevents the
zero-delay busy-loop |
| `WaitTxToBeMined` | `2s` | matches AggOracle |
| `GetReceiptMaxTime` (`WaitReceiptMaxTime`) | `250ms` | matches
AggOracle (now that the key is fixed) |
| `GetReceiptWaitInterval` (`WaitReceiptCheckInterval`) | `1s` | matches
AggOracle |
| `GasPriceMarginFactor` | `1` (any value `<= 0` is coerced to `1`) | a
zero-or-negative factor is never a legitimate request — it means "never
send"; `1` is a neutral no-margin multiplier |
| `SafeStatusL1NumberOfBlocks` | `5` | matches AggOracle |
| `FinalizedStatusL1NumberOfBlocks` | `10` | matches AggOracle |
| `EstimateGasMaxRetries` | `1` | matches AggOracle |

A note on the last three: the vendored module documents `0` as a
*meaningful* sentinel for
`SafeStatusL1NumberOfBlocks`/`FinalizedStatusL1NumberOfBlocks` ("use the
network's own safe/finalized tag") and for `EstimateGasMaxRetries`
("retry forever"). These aren't zero-values that are unconditionally
broken the way the durations and gas margin are. But aggkit's own
`AggOracle` path already overrides all three of those module sentinels
with a fixed opinion, so this PR mirrors that same override for
claimers, for consistency across every `EthTxManager` instance in the
binary — rather than leaving claimers as the only place that silently
falls through to the vendored zero-value behavior.

I considered a validation error instead of silent defaulting for some of
these fields, but rejected it: unlike `StoragePath` (already required by
`ClaimerConfig.Validate`), none of these fields have an "obviously
correct" value an operator must supply — they're tuning knobs with the
project's own established defaults, and requiring every deployment to
spell out all eight of them just to avoid a footgun would only reproduce
the same gap the next time a field is added to the vendored config.
Defaulting to the project's own already-established opinion is safer
than either silently zero-valuing or hard-failing.

### Testing (fix 1)
- `go build ./...` — passes.
- `go test ./autoclaim/... ./config/...` — passes (existing suite
unaffected; added `TestApplyDefaultsFillsUnsetEthTxManagerFields`,
`TestApplyDefaultsPreservesExplicitlySetEthTxManagerFields`,
`TestApplyDefaultsCoercesNonPositiveGasPriceMarginFactor` in
`autoclaim/config/config_test.go`).

## 2. Default bridge-detector start blocks to a recent lookback, not
genesis

### Evidence

Both bridge detectors' start-block config fields —
`L1ToL2BridgeDetector.StartBlock` and
`L2ToLxBridgeDetector.StartL1Block` (`autoclaim/config/config.go`, ~:67
and ~:81) — were plain
`uint64`, so `0` was indistinguishable from "unset", and `0` means scan
from L1 genesis. This is the
same zero-value-means-something-dangerous class as the
`GasPriceMarginFactor` fix in section 1.

In the same production deployment: a rollout that omitted these scanned
**~6.9M Sepolia blocks**,
taking ~19h, **twice** (once after a datadir wipe), and would have
mass-enqueued years of stale
deposits the moment `DryRun` was lifted. The workaround was hand-pinning
a wall-clock-derived magic
number (head minus 7 days) with a "re-pin if the deploy slips past date
X" comment, which goes stale
by construction. On the L2-to-Lx side, if the resolved LER was zero or
the configured block predated
the first L1 info tree leaf, `initialFromLER`
(`autoclaim/bridgedetector/l2_to_lx.go`, ~:592) **silently
fell back to full history** — invisible, and with no in-product recovery
for bridges that fall below
a detector's start block (they are permanently unclaimable; the admin
API only acts on
already-detected requests).

### Fix

Both fields become `*uint64`: `nil` (the field absent from config) means
"resolve automatically",
an explicit value — including `0` — is used verbatim and never adjusted,
which is what makes this
change backwards compatible for every deployment that already pins a
start block. A new
`StartLookback cfgtypes.Duration` field (default `24h`, applied in
`ApplyDefaults()`) controls how
far back an unset value resolves from.

`mapstructure`/viper in this codebase decode `*uint64` correctly out of
the box — verified directly
against this repo's own `viper`/`mapstructure` versions before
committing to the pointer approach
(absent key → `nil`; `= 0` → pointer to `0`; `= 42` → pointer to `42`) —
so no sentinel value or
companion `isSet` field was needed.

**Resolution** (`autoclaim/bridgedetector/startblock.go`,
`ResolveStartBlock`): when unset, it
binary-searches L1 block headers (`EthClienter.CustomHeaderByNumber`)
for the highest block at or
before `now - StartLookback` — about `log2(head-minBlock)` header
lookups, not a linear scan — then
clamps the result to `[minBlock, head]`. `minBlock` is the L1 bridge
sync's own configured genesis
block for `L1ToL2BridgeDetector` (`BridgeL1Sync.InitialBlockNum`) and
the RollupManager contract's
creation block for `L2ToLxBridgeDetector`
(`L1NetworkConfig.RollupManagerCreationBlock`, which
config validation already guarantees is `> 0`) — a detector can never
usefully start earlier than
the data source it reads from or the contract whose events it needs. It
deliberately does **not**
estimate the block number from an average block-time constant: an
orchestration tool used elsewhere
in this operating window assumed 12.0s/block on Sepolia when the real
figure was 12.512s, which
skewed a multi-hour ETA by 14 hours — reading real timestamps back from
the chain avoids that class
of error entirely.

`StartLookback` defaults to `24h`: a bridge only becomes claimable after
certificate settlement
*and* destination GER injection, which has been observed in production
to take **over an hour**, so
a short window risks leaving valid bridges permanently unclaimable,
while 24h of Sepolia is only
~7,000 blocks versus the ~6.9M actually backfilled by the genesis
default.

The resolution runs once at startup per enabled detector and logs an
`INFO` naming the lookback, the
resolved block and its timestamp, plus a `WARN` that bridges originating
before that block will
never be autoclaimed and need manual claiming.

**`config/default.go`** previously baked `StartBlock = 0` /
`StartL1Block = 0` into the rendered
config template for every deployment. Those lines are removed (not set
to a placeholder — simply
absent), since leaving them in would have made every existing deployment
"explicit genesis" forever
and defeated this fix entirely; the Go-side `ApplyDefaults()` is now the
only place `StartLookback`
gets a default, matching how the EthTxManager defaults in section 1 are
applied.

### Silent-fallback decision: WARN + per-source retry, not a hard error

`initialFromLER`'s two silent-fallback branches (leaf-not-found, zero
LER) now return a new
`ErrCannotResolveInitialLER` sentinel instead of `nil, nil` — but
**only** when the configured start
block is non-zero; `StartL1Block = 0` remains an explicit, honored
request for full history, and
since automatic resolution is always clamped to a strictly-positive
`minBlock`, a literal `0` can
only ever be deliberate operator intent, never a resolution artifact.

I initially made this a hard error, but `initialFromLER`'s caller
(`resolveFromLER` →
`processSource`) propagates any error all the way up through `PollOnce`,
which aborts the **entire**
poll — meaning one source with a bad `StartL1Block` relative to its
history would silently stall
*every other source's* discovery too, until an operator intervened. That
is too large a blast radius
for a per-source config problem, and risks the same "wedged and silently
retrying" pattern the
`EthTxManager` fix in section 1 exists to prevent. Instead,
`processSource` now catches
`ErrCannotResolveInitialLER` specifically and treats it like the
existing finder-miss/not-synced-yet
cases: it logs a prominent `WARN` naming the source and remediation
("lower `StartL1Block`, or set it
to `0`"), and returns `sourceRetryLater` — that source alone is retried
every poll until fixed;
every other source in the same poll is unaffected.

### Testing (fix 2)
- `go build ./...` — passes.
- `go test ./autoclaim/... ./config/... ./cmd/...` — passes. Added
`autoclaim/bridgedetector/startblock_test.go` (`TestResolveStartBlock*`:
explicit value/explicit-zero used verbatim with zero RPC calls, unset
resolves from lookback, clamping at both the head and the configured
floor, L1-client error propagation); updated the two existing
`l2_to_lx_test.go` silent-fallback tests to cover the explicit-genesis
path, and added their non-explicit-genesis counterparts asserting the
new retry-later behavior (no candidates fetched, poll still succeeds,
`SkippedSourceCount` increments); updated `config/config_test.go` and
`autoclaim/runtime/runtime_test.go` fixtures for the pointer field.
- `gofmt -l` — clean.
- `go vet ./...` (whole repo, including test files) — clean.
- `golangci-lint run ./autoclaim/... ./config/... ./cmd/...` (v2.4.0) —
0 issues (one `mnd` finding on the binary-search midpoint calculation
addressed with `//nolint:mnd`, matching the existing convention in
`types/block_finality.go`).

### Also updates `docs/autoclaim.md`

The default-value callouts and config-reference table rows for
`StartBlock`/`StartL1Block`
described the old "default `0`" behavior directly; left as-is they would
have actively misled
operators about the very fields this fix changes, so they are updated in
place alongside two new
`StartLookback` rows. No other content in that file changed.

### Out of scope (follow-ups, not implemented here)

- A Prometheus gauge for the resolved start block.
- Refusing to start when the DB is empty but the chain has history
beyond the configured/resolved
window (the datadir-wipe gap hazard from section 1's incident — a real
problem, but too invasive
  for this PR).

## 🐞 Issues

Cross-references
[#1874](#1874)
and

[0xPolygon/zkevm-ethtx-manager#139](0xPolygon/zkevm-ethtx-manager#139)
(does
not close either).

This PR removes the *cause* (an unconfigured `EthTxManager` reaching
zero values), but the deadlock
filed as zkevm-ethtx-manager#139 remains a separate, module-level
defect: an underpriced-at-creation
transaction can never be re-priced, because re-pricing is gated on
`MonitoredTxStatusSent`, a status
that transaction never reaches. A wedged claimer created before this fix
ships still needs the
operational recovery described there; this PR only prevents new claimers
from getting into that state.

## 📝 Notes

- Kept the diff to the config and logging layers only — no vendored
`zkevm-ethtx-manager` changes.
- **Changelog-worthy behavior change:** a deployment that previously
*omitted*
`L1ToL2BridgeDetector.StartBlock` / `L2ToLxBridgeDetector.StartL1Block`
will now resolve a recent
block via `StartLookback` (default 24h) instead of scanning from
genesis. A deployment that already
sets either field explicitly, to any value including `0`, is unaffected.
- Section 2 (start-block defaulting) is meaningfully larger than section
1 — it adds a new
resolution algorithm and a config-template default, not just a
defaulting pass. The owner chose to
include it here as a second small operational fix from the same
deployment window, but it is the
one most defensible as its own PR if reviewers would rather split it
out.
- The `IgnoreNetworkIDs` skip-source logging change that was previously
part of this PR has been
  dropped from here. It is now handled in
[#1882](#1882), together with the
underlying `l2-to-lx`
cursor fix that log line is the visible symptom of — changing the log
level alone would have hidden
  the evidence while leaving the bug in place.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DJuYNKE9jRLRrCWTrrEJ2T

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants