From a3d97d07b5dd14b10dbfdc426e007847c5a408a8 Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Wed, 8 Jul 2026 15:10:36 -0400 Subject: [PATCH 1/4] address comment --- op-batcher/batcher/config.go | 9 --- op-batcher/batcher/config_test.go | 11 ---- op-batcher/batcher/espresso_active.go | 33 ++++------ op-batcher/batcher/service.go | 9 --- op-batcher/flags/flags.go | 16 ----- op-node/rollup/derive/batch_authenticator.go | 11 ++++ op-node/rollup/derive/blob_data_source.go | 18 +++--- op-node/rollup/derive/data_source.go | 10 +-- .../derive/espresso_blob_data_source_test.go | 62 ++++++++++++------- op-node/rollup/derive/params.go | 17 +++++ 10 files changed, 95 insertions(+), 101 deletions(-) diff --git a/op-batcher/batcher/config.go b/op-batcher/batcher/config.go index c5e608d7038..c6530742453 100644 --- a/op-batcher/batcher/config.go +++ b/op-batcher/batcher/config.go @@ -152,11 +152,6 @@ type CLIConfig struct { PprofConfig oppprof.CLIConfig RPC oprpc.CLIConfig AltDA altda.CLIConfig - - // FallbackAuthLeadTime is the lead time for the fallback batcher's - // authentication gate. See BatcherConfig.FallbackAuthLeadTime in - // service.go and isFallbackAuthRequired in espresso_active.go. - FallbackAuthLeadTime time.Duration } func (c *CLIConfig) Check() error { @@ -196,9 +191,6 @@ func (c *CLIConfig) Check() error { if !flags.ValidDataAvailabilityType(c.DataAvailabilityType) { return fmt.Errorf("unknown data availability type: %q", c.DataAvailabilityType) } - if c.FallbackAuthLeadTime <= 0 { - return fmt.Errorf("FallbackAuthLeadTime must be positive: %v", c.FallbackAuthLeadTime) - } // Most chains' L1s still have only Cancun active, but we don't want to // overcomplicate this check with a dynamic L1 query, so we just use maxBlobsPerBlock. // We want to check for both, blobs and auto da-type. @@ -256,7 +248,6 @@ func NewConfig(ctx *cli.Context) *CLIConfig { PprofConfig: oppprof.ReadCLIConfig(ctx), RPC: oprpc.ReadCLIConfig(ctx), AltDA: altda.ReadCLIConfig(ctx), - FallbackAuthLeadTime: ctx.Duration(flags.FallbackAuthLeadTimeFlag.Name), ThrottleConfig: ThrottleConfig{ AdditionalEndpoints: ctx.StringSlice(flags.AdditionalThrottlingEndpointsFlag.Name), TxSizeLowerLimit: ctx.Uint64(flags.ThrottleTxSizeLowerLimitFlag.Name), diff --git a/op-batcher/batcher/config_test.go b/op-batcher/batcher/config_test.go index 5b0062a1261..192ded7cb94 100644 --- a/op-batcher/batcher/config_test.go +++ b/op-batcher/batcher/config_test.go @@ -34,7 +34,6 @@ func validBatcherConfig() batcher.CLIConfig { BatchType: 0, DataAvailabilityType: flags.CalldataType, TxMgrConfig: txmgr.NewCLIConfig("fake", txmgr.DefaultBatcherFlagValues), - FallbackAuthLeadTime: flags.FallbackAuthLeadTimeFlag.Value, LogConfig: log.DefaultCLIConfig(), MetricsConfig: metrics.DefaultCLIConfig(), PprofConfig: oppprof.DefaultCLIConfig(), @@ -107,16 +106,6 @@ func TestBatcherConfig(t *testing.T) { override: func(c *batcher.CLIConfig) { c.DataAvailabilityType = "foo" }, errString: "unknown data availability type: \"foo\"", }, - { - name: "negative fallback auth lead time", - override: func(c *batcher.CLIConfig) { c.FallbackAuthLeadTime = -time.Second }, - errString: "FallbackAuthLeadTime must be positive", - }, - { - name: "zero fallback auth lead time", - override: func(c *batcher.CLIConfig) { c.FallbackAuthLeadTime = 0 }, - errString: "FallbackAuthLeadTime must be positive", - }, { name: "zero TargetNumFrames", override: func(c *batcher.CLIConfig) { c.TargetNumFrames = 0 }, diff --git a/op-batcher/batcher/espresso_active.go b/op-batcher/batcher/espresso_active.go index 661e057747b..af4954df192 100644 --- a/op-batcher/batcher/espresso_active.go +++ b/op-batcher/batcher/espresso_active.go @@ -3,7 +3,6 @@ package batcher import ( "context" "fmt" - "time" "github.com/ethereum/go-ethereum/common" ) @@ -14,25 +13,16 @@ import ( // zero BatchAuthenticatorAddress, indicating that the BatchAuthenticator-based // authentication path is not in use. // -// This decision must align with the verifier's per-L1-block fork gate -// (rollupCfg.IsEspresso(l1OriginTime) in data_source.go, which evaluates the -// hardfork activation predicate against the *containing* L1 block's timestamp). Since -// the tx is not yet mined at decision time, its eventual containing block -// has a strictly greater timestamp than the L1 tip the batcher observes: -// -// l1Tip.Time (batcher's view) < l1OriginTime (block containing the tx) -// -// Without compensation, in the window [forkTime − maxL1InclusionDelay, forkTime) -// the batcher would skip authenticateBatchInfo while the verifier — once the -// tx lands in a post-fork block — would require the resulting -// BatchInfoAuthenticated event, silently dropping the batch. -// -// To prevent this, we add Config.FallbackAuthLeadTime to the L1 tip's -// timestamp before evaluating the fork predicate. This makes the batcher -// start authenticating slightly before the verifier requires it. The reverse -// asymmetry (authenticated tx lands pre-fork) is harmless: pre-fork the -// verifier uses sender-based authorization and the auth event is just an -// unrelated L1 tx that does not affect derivation. +// The batcher switches at Espresso activation (tip.Time >= EspressoTime), while the +// verifier only starts enforcing event-based authentication one grace period later +// (derive.BatchAuthEnforcementDelay). +// The gap absorbs the delay between the batcher's gate decision (based on L1 tip time) +// and the batch tx's eventual inclusion (based on containing-block time): a batch +// decided pre-fork that lands post-activation is still accepted under sender +// authorization as long as its inclusion delay stays below the grace period (~20 min). +// The reverse asymmetry (authenticated tx lands before enforcement) is harmless: +// pre-enforcement the verifier uses sender-based authorization and the auth event is +// just an unrelated L1 tx that does not affect derivation. func (l *BatchSubmitter) isFallbackAuthRequired(ctx context.Context) (bool, error) { if l.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { return false, nil @@ -41,6 +31,5 @@ func (l *BatchSubmitter) isFallbackAuthRequired(ctx context.Context) (bool, erro if err != nil { return false, fmt.Errorf("failed to fetch L1 tip for fallback-auth gate: %w", err) } - leadSec := uint64(l.Config.FallbackAuthLeadTime / time.Second) - return l.RollupConfig.IsEspresso(tip.Time + leadSec), nil + return l.RollupConfig.IsEspresso(tip.Time), nil } diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 72ad9806b18..02759ca5156 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -50,14 +50,6 @@ type BatcherConfig struct { // For throttling DA. See CLIConfig in config.go for details on these parameters. ThrottleParams config.ThrottleParams - - // FallbackAuthLeadTime is consulted by the fallback batcher's - // authentication gate to advance the switch to authenticated batches - // relative to the on-chain EspressoTime hardfork. It absorbs the - // worst-case L1 inclusion delay between batcher decision time (L1 tip) - // and verifier evaluation time (containing L1 block). See - // isFallbackAuthRequired in espresso_active.go for details. - FallbackAuthLeadTime time.Duration } // BatcherService represents a full batch-submitter instance and its resources, @@ -117,7 +109,6 @@ func (bs *BatcherService) initFromCLIConfig(ctx context.Context, closeApp contex bs.NetworkTimeout = cfg.TxMgrConfig.NetworkTimeout bs.CheckRecentTxsDepth = cfg.CheckRecentTxsDepth bs.WaitNodeSync = cfg.WaitNodeSync - bs.FallbackAuthLeadTime = cfg.FallbackAuthLeadTime bs.ThrottleParams = config.ThrottleParams{ LowerThreshold: cfg.ThrottleConfig.LowerThreshold, diff --git a/op-batcher/flags/flags.go b/op-batcher/flags/flags.go index 352c9076b3b..f17faf14c52 100644 --- a/op-batcher/flags/flags.go +++ b/op-batcher/flags/flags.go @@ -160,21 +160,6 @@ var ( Value: false, EnvVars: prefixEnvVars("WAIT_NODE_SYNC"), } - FallbackAuthLeadTimeFlag = &cli.DurationFlag{ - Name: "espresso.fallback-auth-lead-time", - Usage: "Lead time for the fallback batcher's Espresso authentication gate. " + - "How far ahead of the on-chain EspressoTime the fallback batcher " + - "starts routing batch txs through BatchAuthenticator.authenticateBatchInfo. " + - "This absorbs worst-case L1 inclusion delay between the batcher's decision " + - "(based on L1 tip time) and the verifier's gate (based on the containing " + - "L1 block's time). Has no effect outside the boundary window around the " + - "EspressoTime hardfork. Must not be negative, and must exceed the worst-case " + - "L1 inclusion delay: with 0 the safety margin is gone, and a batch decided " + - "pre-fork that lands in a post-fork block is silently dropped by the verifier.", - Value: 5 * time.Minute, - EnvVars: prefixEnvVars("ESPRESSO_FALLBACK_AUTH_LEAD_TIME"), - } - // Legacy Flags SequencerHDPathFlag = txmgr.SequencerHDPathFlag ) @@ -203,7 +188,6 @@ var optionalFlags = []cli.Flag{ DataAvailabilityTypeFlag, ActiveSequencerCheckDurationFlag, CompressionAlgoFlag, - FallbackAuthLeadTimeFlag, } func init() { diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index e2c91de9520..66b29e22698 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -13,9 +13,20 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/eth" ) +// isEspressoAuthEnforced returns true once event-based batch authentication is enforced +// at the given L1 origin time: the Espresso fork is active AND at least +// BatchAuthEnforcementDelay has elapsed since activation. Before that, derivation keeps +// accepting sender-authenticated batches. +// The batcher's fallback-auth gate (op-batcher espresso_active.go) +// switches at plain activation time, a full grace period earlier. +func isEspressoAuthEnforced(cfg *rollup.Config, l1OriginTime uint64) bool { + return cfg.IsEspresso(l1OriginTime) && l1OriginTime-*cfg.EspressoTime >= BatchAuthEnforcementDelaySecs +} + var ( // BatchInfoAuthenticatedABI is the event signature for // BatchInfoAuthenticated(bytes32 commitment, address indexed caller). diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index c0bfa701135..df2ebe0f642 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -119,21 +119,21 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // creates a placeholder blobOrCalldata element for each returned blob hash that must be populated // by fillBlobPointers after blob bodies are retrieved. // -// Pre-Espresso (the L1 origin time of `ref` is < *EspressoTime, or unset), -// this runs upstream Optimism semantics: filter by batch inbox + sender == -// batcher. +// Before Espresso event-auth is enforced (Espresso inactive at the L1 origin time of +// `ref`, or within BatchAuthEnforcementDelay of activation), this runs upstream +// Optimism semantics: filter by batch inbox + sender == batcher. // -// Post-Espresso, it collects all authenticated batch hashes from a lookback +// Once enforced, it collects all authenticated batch hashes from a lookback // window once and rejects any batch whose commitment hash is not in the // authenticated set. For blob transactions, the batch hash is computed from // the concatenated blob versioned hashes. func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *DataSourceConfig, batcherAddr common.Address, fetcher L1Fetcher, ref eth.L1BlockRef, logger log.Logger) ([]blobOrCalldata, []common.Hash, error) { - // Only collect authenticated batch commitments when the Espresso fork is - // active at the L1 origin time of the block we're scanning. Pre-fork, the - // upstream sender-based authorization path is used and authenticatedHashes - // is unused. + // Only collect authenticated batch commitments once event-based authentication is + // enforced at the L1 origin time of the block we're scanning (Espresso active plus + // the enforcement grace period). Before that, the upstream sender-based + // authorization path is used and authenticatedHashes is unused. var authenticatedHashes map[common.Hash]common.Address - if config.rollupCfg.IsEspresso(ref.Time) { + if isEspressoAuthEnforced(config.rollupCfg, ref.Time) { var err error authenticatedHashes, err = CollectAuthenticatedBatches( ctx, fetcher, ref, config.rollupCfg.BatchAuthenticatorAddress, config.batchAuthCaches, logger, diff --git a/op-node/rollup/derive/data_source.go b/op-node/rollup/derive/data_source.go index 74b50335b2f..4da805af043 100644 --- a/op-node/rollup/derive/data_source.go +++ b/op-node/rollup/derive/data_source.go @@ -165,12 +165,13 @@ func isAuthorizedBatchSender(tx *types.Transaction, l1Signer types.Signer, batch // block (passed as l1OriginTime), mirroring the data-source layer's ecotoneTime // treatment. // -// Pre-Espresso (l1OriginTime < *EspressoTime, or unset): +// Before enforcement (Espresso inactive, or within BatchAuthEnforcementDelay of +// activation): // // upstream behavior — the L1 sender of the transaction must match the configured // batcher address. The authenticatedHashes map is unused. // -// Post-Espresso: +// Once enforced: // // the batch's commitment hash must appear in authenticatedHashes (i.e. a // BatchInfoAuthenticated event was emitted for this commitment within the @@ -188,8 +189,9 @@ func isBatchTxAuthorized( l1OriginTime uint64, logger log.Logger, ) bool { - if !dsCfg.rollupCfg.IsEspresso(l1OriginTime) { - // Pre-fork: upstream sender-based authorization. + if !isEspressoAuthEnforced(dsCfg.rollupCfg, l1OriginTime) { + // Pre-enforcement (pre-fork or within the grace period): upstream + // sender-based authorization. return isAuthorizedBatchSender(tx, dsCfg.l1Signer, batcherAddr, logger) } // Post-fork: the commitment must have been authenticated within the lookback window. diff --git a/op-node/rollup/derive/espresso_blob_data_source_test.go b/op-node/rollup/derive/espresso_blob_data_source_test.go index 052801d263f..49787e4c3cf 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -101,9 +101,10 @@ func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1Block // TestDataAndHashesFromTxsEventAuth tests event-based batch authentication for both // calldata and blob transactions in the blob data source path. // -// Event-based authentication is only active post-Espresso; the fixture -// activates the fork at L1 origin time 0 (genesis) so all test refs satisfy -// ref.Time >= *EspressoTime. +// Event-based authentication is only enforced once the fork has been active for +// BatchAuthEnforcementDelay; the fixture activates the fork at L1 origin time 0 +// (genesis) and all test refs use Time = BatchAuthEnforcementDelay — the first +// enforced timestamp. func TestDataAndHashesFromTxsEventAuth(t *testing.T) { rng := rand.New(rand.NewSource(9999)) privateKey := testutils.InsecureRandomKey(rng) @@ -141,7 +142,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { } calldataTx, _ := types.SignNewTx(privateKey, signer, txData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} batchHash := ComputeCalldataBatchHash(calldataTx.Data()) ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) @@ -166,7 +167,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { } blobTx, _ := types.SignNewTx(privateKey, signer, blobTxData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} batchHash := ComputeBlobBatchHash([]common.Hash{blobHash}) ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) @@ -193,7 +194,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // Signed by an unknown key (not batcherAddr), no auth event — should be rejected calldataTx, _ := types.SignNewTx(altKey, signer, txData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) // no auth events data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) @@ -217,7 +218,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // because all batchers now require event-based authentication calldataTx, _ := types.SignNewTx(privateKey, signer, txData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) // no auth events data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) @@ -241,7 +242,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // emitted by that same alt address — should be accepted. calldataTx, _ := types.SignNewTx(altKey, signer, txData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} batchHash := ComputeCalldataBatchHash(calldataTx.Data()) ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, altAddr, []common.Hash{batchHash}) @@ -268,7 +269,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // The submitter must match the auth caller — should be rejected. calldataTx, _ := types.SignNewTx(altKey, signer, txData) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} batchHash := ComputeCalldataBatchHash(calldataTx.Data()) ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) @@ -302,7 +303,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { tx2 := newInboxTx(privateKey) tx3 := newInboxTx(altKey) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{ComputeCalldataBatchHash(tx1.Data())}) @@ -337,7 +338,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { BlobHashes: []common.Hash{blobHash}, }) - ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{ ComputeCalldataBatchHash(calldataTx.Data()), ComputeBlobBatchHash([]common.Hash{blobHash}), @@ -360,9 +361,12 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // blob data source path (dataAndHashesFromTxs) across a single fixed DataSourceConfig. // // This is the path a chain with Ecotone active actually runs: OpenData always selects the -// blob source, and calldata (type-2) batches flow through its non-blob branch. Pre-Espresso -// (L1 origin time < EspressoTime) must use upstream sender-based authorization with no event -// scanning; at and after activation it must switch to event-based authentication. +// blob source, and calldata (type-2) batches flow through its non-blob branch. Sender-based +// authorization (with no event scanning) must be used both pre-fork AND through the grace +// window [EspressoTime, EspressoTime+BatchAuthEnforcementDelay); event-based authentication +// is enforced only from EspressoTime+BatchAuthEnforcementDelay onward. The grace window is +// what lets the batcher switch at activation with no configured lead time: a batch decided +// pre-fork that lands post-activation is still accepted under sender auth. func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { rng := rand.New(rand.NewSource(7777)) privateKey := testutils.InsecureRandomKey(rng) @@ -432,29 +436,45 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { l1F.AssertExpectations(t) }) - t.Run("activation block: same batcher tx rejected without auth event", func(t *testing.T) { - // At the exact activation time (ref.Time == EspressoTime) the event-based path is - // active, so a sender-only batcher tx is no longer sufficient. + t.Run("grace window: batcher accepted via sender auth, no event scan", func(t *testing.T) { l1F := &testutils.MockL1Source{} txData := testutils.RandomData(rng, 200) tx := newCalldataBatchTx(t, privateKey, txData) - ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + for _, refTime := range []uint64{espressoTime, espressoTime + BatchAuthEnforcementDelaySecs - 1} { + ref := eth.L1BlockRef{Number: 1, Time: refTime, Hash: testutils.RandomHash(rng)} + data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 1, len(data), "grace-window batcher tx should be accepted via sender-based auth") + require.Equal(t, 0, len(hashes)) + require.Equal(t, eth.Data(txData), *data[0].calldata) + } + l1F.AssertExpectations(t) + }) + + t.Run("enforcement time: same batcher tx rejected without auth event", func(t *testing.T) { + // At ref.Time == EspressoTime+BatchAuthEnforcementDelay the event-based path is + // enforced, so a sender-only batcher tx is no longer sufficient. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 200) + tx := newCalldataBatchTx(t, privateKey, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime + BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) - require.Equal(t, 0, len(data), "post-fork batcher tx without an auth event must be rejected") + require.Equal(t, 0, len(data), "enforced batcher tx without an auth event must be rejected") require.Equal(t, 0, len(hashes)) l1F.AssertExpectations(t) }) - t.Run("activation block: same batcher tx accepted with auth event", func(t *testing.T) { + t.Run("enforcement time: same batcher tx accepted with auth event", func(t *testing.T) { l1F := &testutils.MockL1Source{} txData := testutils.RandomData(rng, 200) tx := newCalldataBatchTx(t, privateKey, txData) - ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + ref := eth.L1BlockRef{Number: 1, Time: espressoTime + BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} batchHash := ComputeCalldataBatchHash(tx.Data()) ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) diff --git a/op-node/rollup/derive/params.go b/op-node/rollup/derive/params.go index 4f7728a3736..796810eef6d 100644 --- a/op-node/rollup/derive/params.go +++ b/op-node/rollup/derive/params.go @@ -31,6 +31,23 @@ const MaxSpanBatchElementCount = 10_000_000 // L1 congestion or batcher restarts. const BatchAuthLookbackWindow uint64 = 100 +// BatchAuthEnforcementDelay is the number of seconds after the EspressoTime activation +// during which derivation still accepts sender-authenticated batches. Event-based batch +// authentication is only enforced for L1 blocks with origin time >= EspressoTime + +// BatchAuthEnforcementDelay. +// +// This grace period exists so the batcher can switch to authenticated submission at +// activation time without a configured lead time: a batch decided pre-fork (no auth +// event sent) that lands in a post-activation L1 block is still accepted under sender +// authorization, as long as its inclusion delay is below the grace period. The batcher +// starts authenticating at activation, a full grace period before enforcement. +// +// Sized to the duration of one full BatchAuthLookbackWindow at the nominal 12s L1 slot +// time (~20 minutes) — far above any realistic L1 inclusion delay. Under missed L1 +// slots the delay covers fewer than BatchAuthLookbackWindow blocks, which is fine: the +// bound that matters is time (inclusion delay), not block count. +const BatchAuthEnforcementDelaySecs uint64 = BatchAuthLookbackWindow * 12 + // DuplicateErr is returned when a newly read frame is already known var DuplicateErr = errors.New("duplicate frame") From 4e363b5a07327ef3ba0c4fc1c56bc31bbd498c7b Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Thu, 9 Jul 2026 09:58:45 -0400 Subject: [PATCH 2/4] Update op-node/rollup/derive/batch_authenticator.go Co-authored-by: piersy --- op-node/rollup/derive/batch_authenticator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index 66b29e22698..123a2d1d696 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -24,7 +24,7 @@ import ( // The batcher's fallback-auth gate (op-batcher espresso_active.go) // switches at plain activation time, a full grace period earlier. func isEspressoAuthEnforced(cfg *rollup.Config, l1OriginTime uint64) bool { - return cfg.IsEspresso(l1OriginTime) && l1OriginTime-*cfg.EspressoTime >= BatchAuthEnforcementDelaySecs + return cfg.IsEspresso(l1OriginTime) && l1OriginTime >= *cfg.EspressoTime + BatchAuthEnforcementDelaySecs } var ( From 23cd7d6ded28a5dc3ee4c17764eeb1cb57b6ff0a Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Thu, 9 Jul 2026 10:18:20 -0400 Subject: [PATCH 3/4] lint --- op-node/rollup/derive/batch_authenticator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index 123a2d1d696..e41f9165a05 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -24,7 +24,7 @@ import ( // The batcher's fallback-auth gate (op-batcher espresso_active.go) // switches at plain activation time, a full grace period earlier. func isEspressoAuthEnforced(cfg *rollup.Config, l1OriginTime uint64) bool { - return cfg.IsEspresso(l1OriginTime) && l1OriginTime >= *cfg.EspressoTime + BatchAuthEnforcementDelaySecs + return cfg.IsEspresso(l1OriginTime) && l1OriginTime >= *cfg.EspressoTime+BatchAuthEnforcementDelaySecs } var ( From 317aec32212b5d56bac622fc66ffd1bd2bf9e2a4 Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Thu, 9 Jul 2026 14:21:41 -0400 Subject: [PATCH 4/4] fix documentation --- op-batcher/batcher/espresso_active.go | 14 +++------ op-node/rollup/derive/batch_authenticator.go | 7 ++--- op-node/rollup/derive/blob_data_source.go | 4 +-- op-node/rollup/derive/data_source.go | 5 ++-- .../derive/espresso_blob_data_source_test.go | 17 +++++------ op-node/rollup/derive/params.go | 30 ++++++++++++++----- 6 files changed, 43 insertions(+), 34 deletions(-) diff --git a/op-batcher/batcher/espresso_active.go b/op-batcher/batcher/espresso_active.go index af4954df192..dcd6eface8d 100644 --- a/op-batcher/batcher/espresso_active.go +++ b/op-batcher/batcher/espresso_active.go @@ -13,16 +13,10 @@ import ( // zero BatchAuthenticatorAddress, indicating that the BatchAuthenticator-based // authentication path is not in use. // -// The batcher switches at Espresso activation (tip.Time >= EspressoTime), while the -// verifier only starts enforcing event-based authentication one grace period later -// (derive.BatchAuthEnforcementDelay). -// The gap absorbs the delay between the batcher's gate decision (based on L1 tip time) -// and the batch tx's eventual inclusion (based on containing-block time): a batch -// decided pre-fork that lands post-activation is still accepted under sender -// authorization as long as its inclusion delay stays below the grace period (~20 min). -// The reverse asymmetry (authenticated tx lands before enforcement) is harmless: -// pre-enforcement the verifier uses sender-based authorization and the auth event is -// just an unrelated L1 tx that does not affect derivation. +// The gate switches at plain Espresso activation (tip.Time >= EspressoTime), a full grace +// period before the verifier enforces event-based authentication. See +// derive.BatchAuthEnforcementDelaySecs for the full grace-period mechanism and why the +// batcher and verifier gates are offset. func (l *BatchSubmitter) isFallbackAuthRequired(ctx context.Context) (bool, error) { if l.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { return false, nil diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index e41f9165a05..52e9f801608 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -19,10 +19,9 @@ import ( // isEspressoAuthEnforced returns true once event-based batch authentication is enforced // at the given L1 origin time: the Espresso fork is active AND at least -// BatchAuthEnforcementDelay has elapsed since activation. Before that, derivation keeps -// accepting sender-authenticated batches. -// The batcher's fallback-auth gate (op-batcher espresso_active.go) -// switches at plain activation time, a full grace period earlier. +// BatchAuthEnforcementDelaySecs has elapsed since activation. Before that, derivation +// keeps accepting sender-authenticated batches. See BatchAuthEnforcementDelaySecs +// (params.go) for the full grace-period mechanism. func isEspressoAuthEnforced(cfg *rollup.Config, l1OriginTime uint64) bool { return cfg.IsEspresso(l1OriginTime) && l1OriginTime >= *cfg.EspressoTime+BatchAuthEnforcementDelaySecs } diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index df2ebe0f642..2f6cfcf9692 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -120,7 +120,7 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // by fillBlobPointers after blob bodies are retrieved. // // Before Espresso event-auth is enforced (Espresso inactive at the L1 origin time of -// `ref`, or within BatchAuthEnforcementDelay of activation), this runs upstream +// `ref`, or within BatchAuthEnforcementDelaySecs of activation), this runs upstream // Optimism semantics: filter by batch inbox + sender == batcher. // // Once enforced, it collects all authenticated batch hashes from a lookback @@ -159,7 +159,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D batchHash = ComputeCalldataBatchHash(tx.Data()) } - // Check authorization (sender-based pre-fork; event-based post-fork). + // Check authorization (sender-based before enforcement; event-based once enforced). if !isBatchTxAuthorized(tx, *config, batcherAddr, batchHash, authenticatedHashes, ref.Time, logger) { continue } diff --git a/op-node/rollup/derive/data_source.go b/op-node/rollup/derive/data_source.go index 4da805af043..ae84f2ad942 100644 --- a/op-node/rollup/derive/data_source.go +++ b/op-node/rollup/derive/data_source.go @@ -165,7 +165,7 @@ func isAuthorizedBatchSender(tx *types.Transaction, l1Signer types.Signer, batch // block (passed as l1OriginTime), mirroring the data-source layer's ecotoneTime // treatment. // -// Before enforcement (Espresso inactive, or within BatchAuthEnforcementDelay of +// Before enforcement (Espresso inactive, or within BatchAuthEnforcementDelaySecs of // activation): // // upstream behavior — the L1 sender of the transaction must match the configured @@ -194,7 +194,8 @@ func isBatchTxAuthorized( // sender-based authorization. return isAuthorizedBatchSender(tx, dsCfg.l1Signer, batcherAddr, logger) } - // Post-fork: the commitment must have been authenticated within the lookback window. + // Once enforced (fork active and past the grace period): the commitment must have been + // authenticated within the lookback window. authCaller, ok := authenticatedHashes[batchHash] if !ok { logger.Warn("batch not authenticated", diff --git a/op-node/rollup/derive/espresso_blob_data_source_test.go b/op-node/rollup/derive/espresso_blob_data_source_test.go index 49787e4c3cf..e38d2af21a3 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -102,8 +102,8 @@ func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1Block // calldata and blob transactions in the blob data source path. // // Event-based authentication is only enforced once the fork has been active for -// BatchAuthEnforcementDelay; the fixture activates the fork at L1 origin time 0 -// (genesis) and all test refs use Time = BatchAuthEnforcementDelay — the first +// BatchAuthEnforcementDelaySecs; the fixture activates the fork at L1 origin time 0 +// (genesis) and all test refs use Time = BatchAuthEnforcementDelaySecs — the first // enforced timestamp. func TestDataAndHashesFromTxsEventAuth(t *testing.T) { rng := rand.New(rand.NewSource(9999)) @@ -361,12 +361,11 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // blob data source path (dataAndHashesFromTxs) across a single fixed DataSourceConfig. // // This is the path a chain with Ecotone active actually runs: OpenData always selects the -// blob source, and calldata (type-2) batches flow through its non-blob branch. Sender-based -// authorization (with no event scanning) must be used both pre-fork AND through the grace -// window [EspressoTime, EspressoTime+BatchAuthEnforcementDelay); event-based authentication -// is enforced only from EspressoTime+BatchAuthEnforcementDelay onward. The grace window is -// what lets the batcher switch at activation with no configured lead time: a batch decided -// pre-fork that lands post-activation is still accepted under sender auth. +// blob source, and calldata (type-2) batches flow through its non-blob branch. It walks the +// gate across the fork boundary: sender-based authorization (no event scanning) both pre-fork +// AND through the grace window [EspressoTime, EspressoTime+BatchAuthEnforcementDelaySecs), then +// event-based authentication from EspressoTime+BatchAuthEnforcementDelaySecs onward. See +// BatchAuthEnforcementDelaySecs (params.go) for the full grace-period mechanism. func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { rng := rand.New(rand.NewSource(7777)) privateKey := testutils.InsecureRandomKey(rng) @@ -453,7 +452,7 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { }) t.Run("enforcement time: same batcher tx rejected without auth event", func(t *testing.T) { - // At ref.Time == EspressoTime+BatchAuthEnforcementDelay the event-based path is + // At ref.Time == EspressoTime+BatchAuthEnforcementDelaySecs the event-based path is // enforced, so a sender-only batcher tx is no longer sufficient. l1F := &testutils.MockL1Source{} txData := testutils.RandomData(rng, 200) diff --git a/op-node/rollup/derive/params.go b/op-node/rollup/derive/params.go index 796810eef6d..a5bd0264d42 100644 --- a/op-node/rollup/derive/params.go +++ b/op-node/rollup/derive/params.go @@ -31,16 +31,32 @@ const MaxSpanBatchElementCount = 10_000_000 // L1 congestion or batcher restarts. const BatchAuthLookbackWindow uint64 = 100 -// BatchAuthEnforcementDelay is the number of seconds after the EspressoTime activation +// BatchAuthEnforcementDelaySecs is the number of seconds after the EspressoTime activation // during which derivation still accepts sender-authenticated batches. Event-based batch // authentication is only enforced for L1 blocks with origin time >= EspressoTime + -// BatchAuthEnforcementDelay. +// BatchAuthEnforcementDelaySecs. // -// This grace period exists so the batcher can switch to authenticated submission at -// activation time without a configured lead time: a batch decided pre-fork (no auth -// event sent) that lands in a post-activation L1 block is still accepted under sender -// authorization, as long as its inclusion delay is below the grace period. The batcher -// starts authenticating at activation, a full grace period before enforcement. +// This is the canonical description of the grace-period mechanism; isEspressoAuthEnforced +// (batch_authenticator.go), the op-batcher fallback-auth gate (isFallbackAuthRequired in +// espresso_active.go), and the fork-boundary tests all defer here. +// +// Behavior by L1 origin time t relative to EspressoTime (E): +// - before enforcement (t < E+delay, i.e. pre-fork or within the grace window): +// upstream sender-based authorization — the batch tx's L1 sender must equal the +// configured batcher address. Auth events are not scanned. +// - enforced (t >= E+delay): event-based authentication only — the batch's commitment +// must have a BatchInfoAuthenticated event within BatchAuthLookbackWindow AND the tx's +// L1 sender must equal the caller that emitted it. Sender-only authorization is rejected. +// +// The grace period lets the batcher switch to authenticated submission at activation +// without a configured lead time. The batcher's gate flips at plain activation (L1 tip +// time >= EspressoTime), a full grace period before the verifier enforces. Because the +// batcher decides on the L1 tip time while the verifier judges by the batch tx's +// containing-block time, a batch decided pre-fork can land in a post-activation block; +// accepting sender auth through the window keeps it valid as long as its inclusion delay +// stays below the grace period. The reverse asymmetry (an authenticated tx landing before +// enforcement) is harmless: pre-enforcement the verifier uses sender-based authorization +// and the auth event is just an unrelated L1 tx that does not affect derivation. // // Sized to the duration of one full BatchAuthLookbackWindow at the nominal 12s L1 slot // time (~20 minutes) — far above any realistic L1 inclusion delay. Under missed L1