feat(tools): force_ger_update — force periodic L1 GER updates#1730
Merged
Conversation
…plumbing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…tdown Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…ionale Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…backend Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…op-pp env) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…ll green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
- config validation: reject non-positive MaxTimeWithoutGERUpdate/CheckInterval/ FilterLogsChunkSize and (poll mode) EventPollInterval — the latter two feed time.NewTicker, which panics on <=0, crashing the tool on boot when omitted - README: fix docker command to override the aggkit ENTRYPOINT; mark newly required config fields - e2e: skip TestForceGERUpdateE2E by default (remove_ger precedent — perturbs the shared post-test bridge health check); runnable manually - run.go: document timer/clock-skew semantics (block ts vs wall clock) - PLAN.md: append PR draft + final summary (section 7) and S8 review log Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…plete) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…1 (open PR) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
… runner TestForceGERUpdateE2E now runs gated on RUN_FORCE_GER_UPDATE_E2E=true instead of an unconditional t.Skip, so a dedicated CI job can opt in while the main test-e2e suite (no env vars) still skips it with zero interference. testmain_test.go's post-test L1<->L2 bridge health check gains an opt-in E2E_SKIP_POSTTEST_BRIDGE_CHECK=true escape hatch (default unset = unchanged behavior), since forcing a GER update legitimately leaves that shared check unhealthy (same reason removeger_test.go skips). Adds a `test-e2e-force_ger_update` Makefile target wiring both env vars, and a new isolated `test-go-e2e-force-ger-update` job in test-go-e2e.yml that reuses the existing docker-image build/pull artifacts rather than duplicating that pipeline; the existing test-go-e2e job is untouched. Also fixes one pre-existing thelper lint finding in forcegerupdate_test.go (missing t.Helper()), unrelated to this change but required to keep `make lint` at 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Update the section-7 PR draft to reflect S10's isolated Tier-2 e2e CI job (test-go-e2e-force-ger-update, RUN_FORCE_GER_UPDATE_E2E / E2E_SKIP_POSTTEST_BRIDGE_CHECK) under Changes Summary, Testing, and Notes; keep the DA-provability motivation and template section order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
TestRunLoop_StaleOnBoot_SendsExactlyOnce asserted the send count after the loop returned, where a tick racing with ctx cancellation could legitimately start a second (harmless) send during shutdown -> flaked in CI (got 2, want 1). Now the send is held in flight and the count is asserted on the stable in-flight window, proving the guard without depending on teardown ordering. TestRunLoop_EventBeforeThreshold_NoSend delivered the reset event from a goroutine after a 5ms sleep, which on a loaded runner could arrive after the threshold tick. Now the event is pre-buffered so the loop's first select consumes it before the first tick, and the ctx window is shorter than the threshold -> deterministic. Verified: go test -race -count=30 -run TestRunLoop passes; package -race + lint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
…iming TestForceGERUpdate/PollMode flaked in CI: scenario 2 (require.Never a 2nd send) tripped because integrationSenderPoll == integrationEventPollInterval (10ms) gave no ordering margin — the in-flight guard could release (send confirmed) before the monitor's poll observed the reset event, letting the next tick fire the documented "redundant harmless" send. Make senderPoll (300ms) ~30x the monitor poll (10ms), encoding the production invariant (mining latency >> event-detection latency), so the guard stays armed until after the reset is observed. Robust under proportional scheduler starvation (ratio-based, not absolute). Also run the WatchMode/PollMode subtests sequentially instead of in parallel: each drives its own simulated backend + monitor/sender loop, and running both at once starves the timing-sensitive poll/commit goroutines on constrained CI runners. Verified: go test -race -count=10 passes; also -count=5 under GOMAXPROCS=1 (heavy starvation) passes; package -race + lint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRmUbKmtYbSjhhRaT3Ea5d
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔄 Changes Summary
force_ger_update: a standalone, long-running CLI tool that guarantees the L1 Global ExitRoot (GER) is updated at least every configurable
X. It watches L1 forUpdateL1InfoTreeevents and, if none happens organically within the window, sends a
bridgeMessagewithforceUpdateGlobalExitRoot = trueto force a new L1 info root.appends a leaf to the L1 info tree that embeds an L1 block hash, and the aggchain proof uses
that block hash to attest to what happened on L1 — including data availability (DA). Anything
posted on L1 after the last L1 info root update is covered by no block hash inside any L1 info
root and therefore cannot be proven until a new update lands. If DA is posted after the last
update, certificate progress stalls until an organic GER update happens — with no bound on the
wait. This tool bounds that wait to
X.FilterLogs, then WSWatchUpdateL1InfoTree(ifL1WSURLset) orFilterLogspolling; sends via the standardzkevm-ethtx-manager; keys viago_signer(local keystore or GCP/AWS KMS).Makefile(build-force_ger_update, added tobuild-tools) and shipped in theDocker image at
/usr/local/bin/force_ger_update.TestForceGERUpdateE2E) now runs in CI on its own dedicated,isolated job/runner (
test-go-e2e-force-ger-updatein.github/workflows/test-go-e2e.yml)instead of being permanently skipped, so it is exercised automatically without risking the shared
test-go-e2eenv/job.📋 Config Updates
[ForceGERUpdate]root section (+ nested[…EthTxManager]/[…EthTxManager.Etherman]). Seetools/force_ger_update/example-config.tomland the README'sconfiguration reference. Mandatory & validated:
L1URL,GlobalExitRootManagerAddr,BridgeAddr,DestinationNetwork(≠ 0),MaxTimeWithoutGERUpdate(> 0),CheckInterval(> 0),FilterLogsChunkSize(> 0), andEventPollInterval(> 0, poll mode only).✅ Testing
make test-unit): configload/validate, monitor (boot scan + watch/poll), sender (calldata/selector, statuses, dry-run),
main loop (stale-on-boot, event-reset, in-flight guard, clean shutdown) —
go test -race ./tools/force_ger_update/...; plus a simulated-backend integration test(
integration_test.go) exercising both watch and poll modes and asserting real on-chainUpdateL1InfoTreeevents and the0x240ff378selector.TestForceGERUpdateE2E(test/e2e/) execs thebuilt binary against the docker-compose
op-ppenv and proves causality — the new GER update'stx
From== tool sender and input starts with0x240ff378. It is env-gated(
RUN_FORCE_GER_UPDATE_E2E=true) so it stays skipped in the sharedmake test-e2e/ maintest-go-e2eCI job (avoiding interference with that job's shared post-test bridge healthcheck), and instead runs automatically in CI on its own dedicated runner —
test-go-e2e-force-ger-update, a new job in.github/workflows/test-go-e2e.ymlthat reuses theexisting docker-image build/pull pipeline and calls
make test-e2e-force_ger_update. Thattarget sets both
RUN_FORCE_GER_UPDATE_E2E=trueandE2E_SKIP_POSTTEST_BRIDGE_CHECK=true(thelatter opts the isolated job out of
TestMain's shared post-test L1↔L2 bridge health check,since forcing GER updates legitimately leaves that check unhealthy — same root cause as the
existing
remove_gere2e skips). Locally:make test-e2e-force_ger_update(docker-compose envmust be up).
DryRun = truesmoke test against a live env logs the boot-derived last-GER-update age withoutsending a transaction.
🐞 Issues
🔗 Related PRs
📝 Notes
lastGERUpdateis an L1 block timestamp; elapsed is measured against the tool'swall clock. Clock skew is immaterial next to a minutes-to-hours threshold and is safe in both
directions (a block timestamp slightly ahead of wall-clock yields a negative elapsed → the tool
simply waits longer; it never over-fires and never panics).
UpdateL1InfoTreeevent, never by the send completing;in poll mode this permits at most one redundant (harmless) send per window if the observation lags
the mine. Sends are serialized by an atomic in-flight guard, so they are never concurrent or
unbounded.
gasOffsetis hardcoded to 0; gas is controlled via the ethtxmanager config(
ForcedGas/GasPriceMarginFactor/MaxGasPriceLimit).RUN_FORCE_GER_UPDATE_E2E,E2E_SKIP_POSTTEST_BRIDGE_CHECK)is documented in the README's "How to test" section and in code comments at both env-var
check-sites.