Skip to content

op-batcher: key the publish gates on batch-auth enforcement, not fork time - #499

Open
philippecamacho wants to merge 6 commits into
espresso/batcherfrom
492-key-publish-gates-on-batch-auth-enforcement
Open

op-batcher: key the publish gates on batch-auth enforcement, not fork time#499
philippecamacho wants to merge 6 commits into
espresso/batcherfrom
492-key-publish-gates-on-batch-auth-enforcement

Conversation

@philippecamacho

@philippecamacho philippecamacho commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #492. Stacked on #459 (espresso/batcher).

Problem

Derivation only enforces event-based batch auth for L1 blocks with origin time >= EspressoTime + BatchAuthEnforcementDelaySecs (a ~20-minute grace window); inside the window it accepts only sender-authenticated batches from the SystemConfig batcher key. The publish gate ignored that boundary and consulted only BatchAuthenticator.activeIsEspresso. So during the window the TEE batcher published batches that mined, burned L1 fees, and were dropped by every verifier, while the fallback batcher stood down on the flag mismatch — up to a full grace window without safe-head progress.

Fixing only the publish gate left a second stall on the same path (caught by @palango in review). The fallback's send path authenticates from EspressoTime onward, and authenticateBatchInfo only accepts the SystemConfig batcher while activeIsEspresso is false. Both deploy scripts initialized it to true, so in the window the fallback's auth leg reverted, the reverted leg cancelled the paired batch leg, and the safe head stalled anyway — now with a reverted tx and a nonce-cancellation no-op every tick.

Fix

Publish gate. Key both roles' publish decisions on the verifier's own predicate, derive.IsEspressoAuthEnforced (newly exported), evaluated at the L1 tip:

  • Pre-enforcement: the fallback publishes unconditionally — the contract is not consulted at all — and the TEE batcher stands down.
  • Post-enforcement: activeIsEspresso plus the sender-identity check decide, exactly as before.
  • Enforcement is monotone in time, so the first true observation is memoized. Errors fail closed; once memoized the boundary no longer needs the tip, and the decision rests on isBatcherActive, which still fails closed.

The auth-dispatch gate (isFallbackAuthRequired) deliberately stays keyed at fork time: the fallback event-authenticates through the window, which keeps its batches valid on both sides of the boundary.

Deploy default. Both deploy scripts now initialize activeIsEspresso to false. This is not a workaround but the invariant the design implies: the fallback must event-authenticate through the window (straddle protection), and the contract only accepts it while the flag is false. Under the old true default, no deployment — greenfield included — had a working publisher for the first grace window. It also decouples the handoff from the fork, since the fallback keeps event-authenticating past the boundary: setActiveIsEspresso(true) can happen whenever the TEE batcher is ready rather than being an operation timed against espresso_time. Runbook in op-batcher/readme.md.

Observability. A batcher standing down and a wedged one previously looked identical. New op_batcher_publish_gate_decision gauge (publishing / awaiting_enforcement / not_active_batcher / boundary_unavailable / active_check_failed) — publishing with a flat batch_tx_submitted is the wedge. The per-tick warnings now go through degradedLog, which matters because a TEE hot standby is the normal post-fork state under the new default. admin_flushBatcher still cannot override the gate (that would make a stood-down batcher double-publish or revert), but it now says so instead of silently dropping the request.

A TEE batcher with EspressoTime unset can never publish, since the boundary is never reached. That was visible only at Debug; it is now a startup warning.

Tests

  • TestShouldSkipPublish_EnforcementBoundary — every boundary scenario for both roles. Pre-enforcement cases run against noContractL1Client, which fails the test on any contract read, proving in-window decisions are contract-independent.
  • TestPublishAuthInvariant_RoleTimeFlag — composes the publish gate with the auth-dispatch gate across role x tip region x flag, asserting that exactly one role publishes and its batch lands. The single excluded cell (grace window with the flag set) is asserted as a stall, recording the dependency on the deploy default. Reverting either gate fix fails it.
  • TestIsBatchAuthEnforcedAtTip_Memoization — the memo's observable effects: one tip fetch in steady state, a tip going backwards staying enforced, and the fail-closed change once latched.

Contract-side, BatchAuthenticator.t.sol already pins the revert the excluded cell depends on.

🤖 Generated with Claude Code


@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73c383eac2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread op-batcher/batcher/espresso_driver.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2dd993418

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +337 to +338
if l.batchAuthEnforced.Load() {
return true, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-evaluate enforcement after L1 reorgs

When the L1 head first reaches the enforcement timestamp and then reorgs to a head below it, this cached true bypasses the tip check forever even though the verifier evaluates enforcement independently for each canonical L1 block. With activeIsEspresso set, the fallback batcher therefore remains stopped while the TEE batcher publishes transactions that pre-enforcement derivation rejects because they are not sent by the SystemConfig batcher, stalling safe-head progress until the replacement chain crosses the boundary again. Avoid permanently memoizing a predicate derived from the reorgable L1 tip.

Useful? React with 👍 / 👎.

@palango

palango commented Aug 18, 2026

Copy link
Copy Markdown

Codex's P1 is right. The publish gate stops consulting activeIsEspresso in the window, but the send path never did. isFallbackAuthRequired is IsEspresso(tip.Time) (espresso_fallback_gate.go:67), so from EspressoTime onward every fallback batch still emits an authenticateBatchInfo call, and that call reverts. BatchAuthenticator.sol:157-163 checks msg.sender against espressoBatcher(); BatchAuthenticator.t.sol:750-757 pins exactly this case. A mined-but-reverted first leg then cancels the batch leg (op-service/txmgr/espresso_txmgr.go:84-90), the channel rewinds, round again. Both deploy scripts initialize the flag to true (DeployBatchAuthenticator.s.sol:82-83, DeployEspresso.s.sol:160-161).

So on a default deployment the twenty minutes goes from "TEE publishes, verifiers drop, stall" to "fallback publishes, auth reverts, stall", now with a reverted tx and a cancellation no-op every tick. The gate's own comment says the fallback publishes "without consulting activeIsEspresso, whose default of true would leave no publisher". With that default there's still no publisher. And espresso_fallback_gate.go:20-22 already writes the mechanism down, but scopes it to pre-fork while the guard actually switches at fork time.

It only works if activeIsEspresso is false from before EspressoTime until the boundary. If that's the runbook, say so, and fix the deploy scripts, because they encode the opposite and no markdown in the repo mentions the flag at all. If it isn't, my instinct is to stop letting a reverted auth leg cancel the batch leg pre-enforcement. In the window the batch derives on sender auth alone (data_source.go:191-195), so the auth is advisory there anyway. The thing I'd avoid is keying isFallbackAuthRequired on enforcement time, since that throws away the straddle protection you deliberately built. Which of the two you meant is really my only question.

Smaller stuff:

  1. Standing down and being wedged look identical. There's no gauge anywhere in Metricer for the gate decision, so both give you a flat batch_tx_submitted and a growing safe-head lag. admin_flushBatcher won't rescue you either: forcePublish is consumed at driver.go:883, and the gate returns at :879. Separate but related, post-enforcement with the flag off the TEE batcher hits the Warn at espresso_active.go:61 every 250ms forever, and l.degradedLog is sitting right there.
  2. The memoization has no test. All 22 subtests build a fresh BatchSubmitter and call the gate once, so the memo is always false on entry and its effect never shows up anywhere. Two cases would cover it: repeated calls doing one tip fetch, and a tip dropping back below the boundary leaving enforced true. There's a third worth pinning if you agree it's intended, which is that once memoized, a tip-fetch failure no longer fails closed at this gate. That's a real behaviour change and nothing records it.
  3. The fake fails soft. Its fallthrough at espresso_publish_gate_test.go:90 returns an error, and the gate turns that into skip, so when I pointed the activeIsEspresso dispatch at a bogus address 8 of the 10 post-enforcement subtests still passed. t.Errorf there instead. Same argument for the nil-backend mock, where t.Fatalf would name the bug rather than SIGSEGV the binary.
  4. types.go:173-183 still says sender-based authorization is rejected post-fork. It isn't, not until the boundary, and that's the first thing someone reads when they set a fork time.

One more for the PR body while you're in there: a TEE batcher with EspressoTime nil now never publishes. Deliberate and test-locked, but it only says so at Debug.

philippecamacho and others added 4 commits August 19, 2026 15:31
… time

Derivation enforces event-based batch authentication only for L1 blocks
with origin time >= EspressoTime + BatchAuthEnforcementDelaySecs; through
that grace window it accepts only sender-authenticated batches from the
SystemConfig batcher key. The publish gate, however, consulted only
BatchAuthenticator.activeIsEspresso (default true), so on the default
migration trajectory the TEE batcher published all through the window -
batches that mined, burned L1 fees, and were dropped by every verifier -
while the fallback batcher, seeing the flag mismatch, stood down. Net
effect: up to a full grace window without safe-head progress.

shouldSkipPublishForActiveSeq now keys ownership on the verifier's own
predicate, derive.IsEspressoAuthEnforced (newly exported), evaluated at
the L1 tip: pre-enforcement the fallback publishes unconditionally (the
contract is not consulted at all) and the TEE batcher stands down;
post-enforcement the activeIsEspresso flag and the sender-identity check
decide, exactly as before. Errors keep failing closed. Gating only the
TEE side would have left a guaranteed no-publisher gap, since the
fallback was standing down on the flag through the window.

The auth-dispatch gate (isFallbackAuthRequired) deliberately stays keyed
at fork time: the fallback event-authenticates from activation, which is
what keeps its batches valid on both sides of the enforcement boundary
and makes the tip-time decision safe under inclusion delay.

The new gate test pins the whole ownership matrix. Pre-enforcement cases
use an L1 mock whose contract backend is nil, so any contract consult
panics the test - proving in-window publish decisions are contract-
independent. Post-enforcement cases run against a fake backend serving
activeIsEspresso/espressoBatcher/systemConfig/batcherHash by selector,
covering flag ownership, identity mismatches, and fail-closed paths.

Closes #492.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e test

Cleanup pass over the publish-gate change:

- The gate fetched the L1 tip on every publishStateToL1 iteration, forever,
  although enforcement is monotone in time. isBatchAuthEnforcedAtTip now
  memoizes the first true observation (atomic.Bool, same pattern as
  clearStateRequested), returning the post-enforcement steady state to the
  contract reads alone - cheaper than the pre-fix gate for both roles - and
  answers an unscheduled fork without any RPC. The one-time transition is
  logged at info; the TEE batcher's per-tick in-window deferral drops to
  debug (it could otherwise repeat for weeks pre-fork).
- The test derives method IDs and return words from the abigen bindings
  (BatchAuthenticatorMetaData/SystemConfigMetaData) instead of hand-typed
  signature strings and hand-packed 32-byte words, so it cannot drift from
  the real ABI; this also drops the per-dispatch keccak recomputation.
- The role-mirrored test table collapses to one row per scenario with a
  want column per role, run for both roles - half the fixture duplication,
  and the pre-enforcement invariant reads directly off the columns. The
  post-enforcement rows now all pin the exact boundary time instead of a
  meaningless mix of enforcementTime and enforcementTime+1.
- Comment dedup: the ownership rationale lives once, on the gate; the
  derive export note and the test doc comment now just point at it, and
  isBatcherActive documents its post-enforcement-only precondition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same content, 18 lines instead of 26: the ownership rule collapses to one
paragraph, keeping only the non-obvious arguments (why the fallback ignores
activeIsEspresso pre-enforcement, why the TEE stands down, and why deciding
on the L1 tip time is safe across the boundary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philippecamacho
philippecamacho force-pushed the 492-key-publish-gates-on-batch-auth-enforcement branch from d2dd993 to 593c55d Compare August 19, 2026 19:31
philippecamacho and others added 2 commits August 19, 2026 16:08
Both deploy scripts initialized activeIsEspresso true, which reverts every
fallback auth call through the pre-enforcement grace window and stalls the
safe head. Default it false and add a property test over role x region x
flag pinning that exactly one batcher can make progress in every cell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a publish_gate_decision gauge so a batcher standing down can be told
apart from a wedged one, throttles the per-tick warnings through degradedLog,
warns when a TEE batcher has no espresso_time scheduled, and pins the
enforcement memo. Test mocks now fail loudly instead of skipping silently.

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

Copy link
Copy Markdown
Collaborator Author

Re smaller stuff + PR body:

Done in 3a2882a

  1. Added op_batcher_publish_gate_decision, a gauge labelled publishing / awaiting_enforcement / not_active_batcher / boundary_unavailable / active_check_failed. publishing with a flat batch_tx_submitted is the wedge; not_active_batcher is a healthy stand-down. The two warnings in espresso_active.go and the gate's two error paths now go through degradedLog — with the P1 fix a TEE hot standby is the normal post-fork state, so that 250ms line would otherwise have been the steady-state log on every Espresso chain.

  2. TestIsBatchAuthEnforcedAtTip_Memoization: four subtests over a fetch-counting tip client, covering both your cases plus the third. I agree that one's intended — it only shifts the decision onto isBatcherActive, which still fails closed — and pinned it as such.

  3. The fallthrough now calls t.Error; I reran your experiment and got the 8 failures where you got 8 silent passes. The nil backend is replaced by noContractL1Client, which t.Fatals with the reason.

  4. Fixed — sender-based authorization holds through the grace period and is rejected only from EspressoTime + BatchAuthEnforcementDelaySecs.

PR body updated.

@philippecamacho

Copy link
Copy Markdown
Collaborator Author

P1 issue addressed in eabfd4c: activeIsEspresso is set to false in the deployer.

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.

2 participants