From f7e587d0b78ef06d2a275d125afcbcb63e240ec1 Mon Sep 17 00:00:00 2001 From: Philippe Camacho Date: Fri, 7 Aug 2026 17:56:04 -0400 Subject: [PATCH 01/12] Espresso: enforce calldata-only DA post-fork (Least Authority Suggestion 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Celo fault-proof host (celo-kona) does not implement the L1Blob preimage hint, so an authenticated blob batch — while accepted by full nodes — would stall fault-proof execution at its L1 block. The spec declares blob DA unsupported (DEC-op-026/n-026), but until now that was an operational restriction only. Enforce it in code, at two layers: - Derivation pipeline (consensus): from Espresso activation onward (including the auth-enforcement grace window), dataAndHashesFromTxs drops blob-carrying inbox transactions before any authorization check, so post-Espresso derivation never requires blob preimages. This covers op-node and the Go op-program alike, since they share the derive package. Pre-fork semantics are unchanged (upstream behavior). - Batcher startup (operational): refuse to start with a blob or auto data-availability type when EspressoTime is scheduled, instead of letting verifiers silently drop every blob batch after activation and stalling the safe head. This makes the existing blob-pair headroom check in checkFallbackAuthConfirmations unreachable in practice; it is kept as defense in depth. Co-Authored-By: Claude Fable 5 --- op-batcher/batcher/service.go | 21 +++++ op-batcher/batcher/service_test.go | 35 ++++++++ op-node/rollup/derive/blob_data_source.go | 20 ++++- .../derive/espresso_blob_data_source_test.go | 80 +++++++++++++++---- 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 4c8892c6c74..d86c91c98fd 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -171,6 +171,9 @@ func (bs *BatcherService) initFromCLIConfig(ctx context.Context, closeApp contex if err := bs.initRollupConfig(ctx); err != nil { return fmt.Errorf("failed to load rollup config: %w", err) } + if err := bs.checkEspressoDataAvailability(cfg); err != nil { + return err + } if err := bs.checkFallbackAuthConfirmations(cfg); err != nil { return err } @@ -259,6 +262,24 @@ func (bs *BatcherService) initRollupConfig(ctx context.Context) error { return nil } +// checkEspressoDataAvailability enforces the calldata-only DA restriction of the +// Espresso integration (DEC-op-026): from Espresso activation the derivation +// pipeline drops blob batch transactions because the Celo fault-proof host cannot +// retrieve blob contents. A blob- or auto-configured batcher would have every blob +// batch silently ignored by verifiers once the fork activates, stalling the safe +// head, so refuse to start instead. +func (bs *BatcherService) checkEspressoDataAvailability(cfg *CLIConfig) error { + if bs.RollupConfig.EspressoTime == nil { + return nil + } + if cfg.DataAvailabilityType != flags.CalldataType { + return fmt.Errorf("data availability type %q is not supported on chains with Espresso scheduled: "+ + "batch data must be posted as calldata only (blob DA is dropped by post-Espresso derivation)", + cfg.DataAvailabilityType) + } + return nil +} + // checkFallbackAuthConfirmations validates that the configured number of L1 // confirmations leaves enough headroom inside BatchAuthLookbackWindow for the // batch tx to land after its auth tx (see sendTxWithFallbackAuth). The bound diff --git a/op-batcher/batcher/service_test.go b/op-batcher/batcher/service_test.go index 1fe7ba29a8e..0de6dd90abd 100644 --- a/op-batcher/batcher/service_test.go +++ b/op-batcher/batcher/service_test.go @@ -12,6 +12,41 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" ) +// TestCheckEspressoDataAvailability: chains with Espresso scheduled are +// calldata-only (DEC-op-026) — post-Espresso derivation drops blob batches, so a +// blob/auto DA configuration must be rejected at startup. +func TestCheckEspressoDataAvailability(t *testing.T) { + espressoTime := uint64(0) + + tests := []struct { + name string + espressoTime *uint64 + daType flags.DataAvailabilityType + wantErr bool + }{ + {"espresso not scheduled: blobs allowed", nil, flags.BlobsType, false}, + {"espresso not scheduled: auto allowed", nil, flags.AutoType, false}, + {"espresso scheduled: calldata allowed", &espressoTime, flags.CalldataType, false}, + {"espresso scheduled: blobs rejected", &espressoTime, flags.BlobsType, true}, + {"espresso scheduled: auto rejected", &espressoTime, flags.AutoType, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bs := &BatcherService{RollupConfig: &rollup.Config{ + EspressoTime: test.espressoTime, + }} + cfg := &CLIConfig{DataAvailabilityType: test.daType} + err := bs.checkEspressoDataAvailability(cfg) + if test.wantErr { + require.ErrorContains(t, err, "calldata only") + } else { + require.NoError(t, err) + } + }) + } +} + // TestCheckFallbackAuthConfirmations: the NumConfirmations headroom bound only // applies when a blob/auto DA batcher can actually emit auth→batch pairs — a // BatchAuthenticator is configured AND the EspressoTime fork is scheduled. diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index 2f6cfcf9692..d0b92388efa 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -125,8 +125,15 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // // 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. +// authenticated set. +// +// From Espresso activation onward (including the enforcement grace window), batch +// data is calldata-only: blob-carrying inbox transactions are dropped entirely, +// authenticated or not. The Celo fault-proof host (celo-kona) does not implement +// the L1Blob preimage hint, so a blob batch accepted here would stall fault-proof +// execution at its L1 block; dropping blob transactions at the fork boundary +// guarantees post-Espresso derivation never depends on blob preimages +// (spec decision DEC-op-026/n-026). 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 once event-based authentication is // enforced at the L1 origin time of the block we're scanning (Espresso active plus @@ -151,6 +158,15 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } + // Post-Espresso, blob DA is unsupported (calldata-only, DEC-op-026): drop blob + // batch transactions before any authorization check so derivation never + // requires blob preimages the Celo fault-proof host cannot supply. + if tx.Type() == types.BlobTxType && config.rollupCfg.IsEspresso(ref.Time) { + logger.Warn("ignoring blob batch tx: blob DA is unsupported post-Espresso", + "txHash", tx.Hash()) + continue + } + // Compute batch hash depending on tx type var batchHash common.Hash if tx.Type() == types.BlobTxType { 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 e38d2af21a3..4e01b4e3df9 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -98,8 +98,9 @@ func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1Block return updatedRef } -// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication for both -// calldata and blob transactions in the blob data source path. +// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication in the blob +// data source path, and that blob-carrying inbox transactions are dropped post-fork +// regardless of authentication (calldata-only DA, DEC-op-026). // // Event-based authentication is only enforced once the fork has been active for // BatchAuthEnforcementDelaySecs; the fixture activates the fork at L1 origin time 0 @@ -155,7 +156,10 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { l1F.AssertExpectations(t) }) - t.Run("authenticated blob tx accepted", func(t *testing.T) { + t.Run("authenticated blob tx rejected: blob DA unsupported post-fork", func(t *testing.T) { + // Even a fully event-authenticated blob batch must be dropped post-fork: + // batch data is calldata-only (DEC-op-026) because the Celo fault-proof + // host cannot supply blob preimages. l1F := &testutils.MockL1Source{} blobHash := testutils.RandomHash(rng) blobTxData := &types.BlobTx{ @@ -173,11 +177,8 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{blobTx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) - require.Equal(t, 1, len(data)) - require.Equal(t, 1, len(blobHashes)) - require.Equal(t, blobHash, blobHashes[0]) // the authenticated blob's hash, not just any - require.Nil(t, data[0].calldata) // blob placeholder - require.Nil(t, data[0].blob) // blob placeholder + require.Equal(t, 0, len(data), "authenticated blob batch must be dropped post-fork") + require.Equal(t, 0, len(blobHashes), "no blob preimages may be requested post-fork") l1F.AssertExpectations(t) }) @@ -317,10 +318,11 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { l1F.AssertExpectations(t) }) - t.Run("multiple authenticated txs each accepted for their own commitment", func(t *testing.T) { + t.Run("mixed calldata+blob block: only the calldata batch accepted", func(t *testing.T) { // One calldata batch and one blob batch in the same block, each authenticated - // via its own commitment (calldata hash vs blob-hash concatenation). Both must - // be accepted and mapped to their own data. + // via its own commitment (calldata hash vs blob-hash concatenation). Post-fork + // only the calldata batch may pass; the blob batch is dropped despite its + // valid authentication (calldata-only DA, DEC-op-026). l1F := &testutils.MockL1Source{} calldataTx, _ := types.SignNewTx(privateKey, signer, &types.LegacyTx{ Nonce: rng.Uint64(), @@ -346,13 +348,10 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx, blobTx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) - require.Equal(t, 2, len(data)) - require.Equal(t, 1, len(blobHashes)) - require.Equal(t, blobHash, blobHashes[0]) - require.NotNil(t, data[0].calldata, "first entry must be the calldata batch") + require.Equal(t, 1, len(data), "only the calldata batch may pass post-fork") + require.Equal(t, 0, len(blobHashes)) + require.NotNil(t, data[0].calldata) require.Equal(t, eth.Data(calldataTx.Data()), *data[0].calldata) - require.Nil(t, data[1].calldata, "second entry must be the blob placeholder") - require.Nil(t, data[1].blob) l1F.AssertExpectations(t) }) } @@ -485,4 +484,51 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { require.Equal(t, eth.Data(txData), *data[0].calldata) l1F.AssertExpectations(t) }) + + // newBlobBatchTx builds a blob batch tx to the inbox, signed by the batcher key. + newBlobBatchTx := func(t *testing.T, blobHash common.Hash) *types.Transaction { + t.Helper() + tx, err := types.SignNewTx(privateKey, signer, &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batchInboxAddr, + BlobHashes: []common.Hash{blobHash}, + }) + require.NoError(t, err) + return tx + } + + t.Run("pre-fork: blob batcher tx accepted via sender auth", func(t *testing.T) { + // Pre-fork the pipeline keeps upstream semantics: blob batches from the + // batcher are accepted and their versioned hashes requested. + l1F := &testutils.MockL1Source{} + blobHash := testutils.RandomHash(rng) + tx := newBlobBatchTx(t, blobHash) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime - 1, 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), "pre-fork blob batcher tx should be accepted via sender-based auth") + require.Equal(t, 1, len(hashes)) + require.Equal(t, blobHash, hashes[0]) + l1F.AssertExpectations(t) + }) + + t.Run("post-fork: blob batcher tx dropped from activation onward", func(t *testing.T) { + // From the moment the fork activates — including the grace window, where + // calldata batches still pass on sender auth — blob batches are dropped + // (calldata-only DA, DEC-op-026). The empty mock also asserts no receipt + // scanning happens pre-enforcement. + l1F := &testutils.MockL1Source{} + tx := newBlobBatchTx(t, 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, 0, len(data), "post-fork blob batcher tx must be dropped") + require.Equal(t, 0, len(hashes), "no blob preimages may be requested post-fork") + } + l1F.AssertExpectations(t) + }) } From 2725e36d9a92e3baa2efe64c556eae501da135be Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 14:53:12 +0200 Subject: [PATCH 02/12] op-node: hoist the Espresso activation check out of the tx loop Espresso activation is a property of the scanned L1 block's origin time, so evaluate it once alongside the enforcement check rather than per transaction. --- op-node/rollup/derive/blob_data_source.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index d0b92388efa..5b9072794bb 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -135,10 +135,13 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // guarantees post-Espresso derivation never depends on blob preimages // (spec decision DEC-op-026/n-026). 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) { + // Espresso activation and event-auth enforcement are both properties of the L1 origin + // time of the block we're scanning, so they hold for every transaction in it. + espressoActive := config.rollupCfg.IsEspresso(ref.Time) + // 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. + // enforced (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 isEspressoAuthEnforced(config.rollupCfg, ref.Time) { var err error @@ -161,7 +164,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D // Post-Espresso, blob DA is unsupported (calldata-only, DEC-op-026): drop blob // batch transactions before any authorization check so derivation never // requires blob preimages the Celo fault-proof host cannot supply. - if tx.Type() == types.BlobTxType && config.rollupCfg.IsEspresso(ref.Time) { + if tx.Type() == types.BlobTxType && espressoActive { logger.Warn("ignoring blob batch tx: blob DA is unsupported post-Espresso", "txHash", tx.Hash()) continue From 4c2bd3fab006284365be6f1743c23d1f8ef933f5 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 14:56:21 +0200 Subject: [PATCH 03/12] op-node: correct the dataAndHashesFromTxs doc comment The pre-enforcement paragraph claimed upstream Optimism semantics for the whole function, including the grace window, which the calldata-only rule below it contradicts. Scope it to authorization, state the inbox filter once, and order the rules as the code applies them. --- op-node/rollup/derive/blob_data_source.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index 5b9072794bb..6cb43ef29ba 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -119,13 +119,8 @@ 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. // -// Before Espresso event-auth is enforced (Espresso inactive at the L1 origin time of -// `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 -// window once and rejects any batch whose commitment hash is not in the -// authenticated set. +// Every transaction is filtered by the batch inbox address first. Two further rules then +// apply, both keyed on the L1 origin time of `ref`. // // From Espresso activation onward (including the enforcement grace window), batch // data is calldata-only: blob-carrying inbox transactions are dropped entirely, @@ -134,6 +129,12 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // execution at its L1 block; dropping blob transactions at the fork boundary // guarantees post-Espresso derivation never depends on blob preimages // (spec decision DEC-op-026/n-026). +// +// The transactions that survive that rule are authorized by upstream Optimism semantics +// (sender == batcher) until Espresso event-auth is enforced, which happens once Espresso +// has been active for BatchAuthEnforcementDelaySecs. 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. 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) { // Espresso activation and event-auth enforcement are both properties of the L1 origin // time of the block we're scanning, so they hold for every transaction in it. @@ -161,7 +162,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } - // Post-Espresso, blob DA is unsupported (calldata-only, DEC-op-026): drop blob + // Post-Espresso, blob DA is unsupported (calldata-only): drop blob // batch transactions before any authorization check so derivation never // requires blob preimages the Celo fault-proof host cannot supply. if tx.Type() == types.BlobTxType && espressoActive { From e9cb1196c67593104ff8f8fdc2324d0a88be1137 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 14:58:24 +0200 Subject: [PATCH 04/12] op-node: note that the blob batch-hash arm is dead post-Espresso Blob txs only get past the calldata-only drop pre-Espresso, where authorization is sender-based and never reads the batch hash. Record why the arm stays rather than folding into the calldata one. --- op-node/rollup/derive/blob_data_source.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index 6cb43ef29ba..3f0797d7d06 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -171,7 +171,12 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } - // Compute batch hash depending on tx type + // Compute batch hash depending on tx type. The blob arm computes a value nothing + // reads: a blob tx only gets past the drop above pre-Espresso, and pre-Espresso + // isBatchTxAuthorized takes the sender-based path, which ignores batchHash. Keep it + // anyway. Folding it into the calldata arm would hash a blob tx over its + // usually-empty calldata, so if the drop above were ever narrowed, every blob batch + // would fail authentication for a reason the logs would not explain. var batchHash common.Hash if tx.Type() == types.BlobTxType { batchHash = ComputeBlobBatchHash(tx.BlobHashes()) @@ -191,6 +196,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } // handle blob batcher transactions by extracting their blob hashes, ignoring any calldata. + // Pre-Espresso only, for the reason given at the batch hash above. if len(tx.Data()) > 0 { log.Warn("blob tx has calldata, which will be ignored", "txhash", tx.Hash()) } From 3018bab5886407057b6c0ac02e7bee7ea68eab53 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 14:59:57 +0200 Subject: [PATCH 05/12] op-batcher: record that the fallback-auth blob path is unreachable checkEspressoDataAvailability runs first and rejects the only configuration checkFallbackAuthConfirmations bounds, so the check cannot fire and the serialized blob send path cannot run. Keep both, and say so in the code. --- op-batcher/batcher/fallback_auth.go | 4 +++- op-batcher/batcher/service.go | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index 419fb68d36e..7c454d56818 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -94,7 +94,9 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca ) if len(candidate.Blobs) > 0 { - // SendPair doesn't support blobs. + // SendPair doesn't support blobs. Unreachable while calldata-only DA is + // enforced: checkEspressoDataAvailability refuses to start a blob- or + // auto-configured batcher on a chain with EspressoTime set. l.sendFallbackAuthSerialized(transactionReference, authReference, verifyCandidate, candidate, queue, receiptsCh) return } diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index d86c91c98fd..89a8c6725b5 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -284,15 +284,21 @@ func (bs *BatcherService) checkEspressoDataAvailability(cfg *CLIConfig) error { // confirmations leaves enough headroom inside BatchAuthLookbackWindow for the // batch tx to land after its auth tx (see sendTxWithFallbackAuth). The bound // only applies when the BatchAuthenticator is configured on the chain, which -// is only known once the rollup config is loaded +// is only known once the rollup config is loaded. +// +// While calldata-only DA is enforced this cannot return an error: it runs after +// checkEspressoDataAvailability, which rejects the one configuration the bound +// applies to, a scheduled EspressoTime with a non-calldata DA type. It is kept as a +// second line of defence and applies again if the restriction is ever lifted. func (bs *BatcherService) checkFallbackAuthConfirmations(cfg *CLIConfig) error { if bs.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { return nil } // Fallback auth is gated behind the EspressoTime hardfork // (dispatchAuthenticatedSendTx): with no activation scheduled no - // auth→batch pair can be emitted, so the bound does not apply. A future - // activation must still be checked — it switches the send path mid-run. + // auth→batch pair can be emitted, so the bound does not apply. A scheduled + // activation counts the same as an active one, since it switches the send + // path mid-run. if bs.RollupConfig.EspressoTime == nil { return nil } From f324bd26ad55ddd679007f0d81491c3efecdd667 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:07:04 +0200 Subject: [PATCH 06/12] espresso: scope the blob-preimage guarantee to post-fork L1 blocks Derivation's drop only covers blocks at or after espresso_time; the pre-fork blocks a proof walks back through are covered by the batcher gate, which keys on espresso_time being set rather than active. Say why that asymmetry stays. --- op-batcher/batcher/service.go | 5 +++++ op-node/rollup/derive/blob_data_source.go | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 89a8c6725b5..eb2ad38096f 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -268,6 +268,11 @@ func (bs *BatcherService) initRollupConfig(ctx context.Context) error { // retrieve blob contents. A blob- or auto-configured batcher would have every blob // batch silently ignored by verifiers once the fork activates, stalling the safe // head, so refuse to start instead. +// +// Deliberately broader than derivation's gate, which drops blobs only once Espresso +// is active: a proof walks back channel_timeout L1 blocks into pre-fork territory, and +// this check is the only thing keeping blob batches out of that window. Do not narrow +// it to IsEspresso. func (bs *BatcherService) checkEspressoDataAvailability(cfg *CLIConfig) error { if bs.RollupConfig.EspressoTime == nil { return nil diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index 3f0797d7d06..e242f32de64 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -126,9 +126,11 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // data is calldata-only: blob-carrying inbox transactions are dropped entirely, // authenticated or not. The Celo fault-proof host (celo-kona) does not implement // the L1Blob preimage hint, so a blob batch accepted here would stall fault-proof -// execution at its L1 block; dropping blob transactions at the fork boundary -// guarantees post-Espresso derivation never depends on blob preimages -// (spec decision DEC-op-026/n-026). +// execution at its L1 block. +// +// That only covers L1 blocks at or after espresso_time. A proof also walks back +// channel_timeout blocks into pre-fork territory; those are kept blob-free by the +// batcher (checkEspressoDataAvailability), not by consensus. // // The transactions that survive that rule are authorized by upstream Optimism semantics // (sender == batcher) until Espresso event-auth is enforced, which happens once Espresso From 817f73eddadc7d5e7f4c215b0e55e3271ddccf49 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:08:05 +0200 Subject: [PATCH 07/12] op-batcher: state what a dropped blob batch actually costs Not an indefinite safe-head stall: derivation forces empty batches once the sequence window expires, and the verifier reorgs the unsafe chain away. --- op-batcher/batcher/service.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index eb2ad38096f..a0af0e9ecad 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -263,11 +263,12 @@ func (bs *BatcherService) initRollupConfig(ctx context.Context) error { } // checkEspressoDataAvailability enforces the calldata-only DA restriction of the -// Espresso integration (DEC-op-026): from Espresso activation the derivation +// Espresso integration: from Espresso activation the derivation // pipeline drops blob batch transactions because the Celo fault-proof host cannot // retrieve blob contents. A blob- or auto-configured batcher would have every blob -// batch silently ignored by verifiers once the fork activates, stalling the safe -// head, so refuse to start instead. +// batch silently ignored by verifiers once the fork activates: the safe head stalls +// for one sequence window, then verifiers force empty batches and reorg away the +// unsafe chain, discarding the transactions in it. Refuse to start instead. // // Deliberately broader than derivation's gate, which drops blobs only once Espresso // is active: a proof walks back channel_timeout L1 blocks into pre-fork territory, and From 4086bd96ed2f9be28a8fdf52743dba081a954ed5 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:15:44 +0200 Subject: [PATCH 08/12] op-node: correct what the batch hash helpers claim about the contract authenticateBatchInfo takes an opaque bytes32 and has no calldata or blob validation path; the encoding is agreed off-chain. Note that the blob helper has no live caller while calldata-only DA is enforced. --- op-node/rollup/derive/batch_authenticator.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index 52e9f801608..9b5f5aae0f8 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -67,14 +67,18 @@ func NewBatchAuthCaches() *BatchAuthCaches { return &BatchAuthCaches{AuthCache: authCache, RefCache: refCache} } -// ComputeCalldataBatchHash computes keccak256(calldata), matching the BatchAuthenticator -// contract's calldata batch validation path. +// ComputeCalldataBatchHash computes keccak256(calldata), the commitment a calldata batch +// is authenticated under. BatchAuthenticator.authenticateBatchInfo takes the commitment as +// an opaque bytes32 and never inspects how it was derived, so this encoding is agreed +// off-chain between the batcher and derivation. func ComputeCalldataBatchHash(data []byte) common.Hash { return crypto.Keccak256Hash(data) } -// ComputeBlobBatchHash computes keccak256(concat(blobHashes)), matching the BatchAuthenticator -// contract's blob batch validation path. +// ComputeBlobBatchHash computes keccak256(concat(blobHashes)), the same commitment for a +// blob batch, agreed off-chain as above. No live path consumes it while calldata-only DA +// is enforced: derivation drops blob batches before hashing them, and the batcher cannot +// start with blob DA once espresso_time is set. func ComputeBlobBatchHash(blobHashes []common.Hash) common.Hash { concatenated := make([]byte, 32*len(blobHashes)) for i, h := range blobHashes { From b6b5ee8ac86226b3ef54e0466f475ebbf62919b9 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:16:06 +0200 Subject: [PATCH 09/12] op-batcher: note sendFallbackAuthSerialized has no reachable caller --- op-batcher/batcher/fallback_auth.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index 7c454d56818..678a7f8cda4 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -121,6 +121,8 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca // sendFallbackAuthSerialized submits an auth+batch pair serially: the auth tx is sent // and confirmed first, then the batch tx. It runs on the publishing-loop goroutine and // blocks it for the pair's full confirmation cycle, so consecutive pairs never overlap. +// Its only caller is the blob branch of sendTxWithFallbackAuth, so it cannot run while +// calldata-only DA is enforced. func (l *BatchSubmitter) sendFallbackAuthSerialized(transactionReference, authReference txRef, verifyCandidate txmgr.TxCandidate, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { authReceiptCh := make(chan txmgr.TxReceipt[txRef], 1) queue.Send(authReference, verifyCandidate, authReceiptCh) From 4c11ca062ff15b5a2da5a5a7c6f3f07e2fcb3eed Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:19:03 +0200 Subject: [PATCH 10/12] op-batcher: mark the tests that pin configurations the batcher won't start Three TestCheckFallbackAuthConfirmations cases, the five tests built on testBlobCandidate and the blob parity subtest all describe blob DA, which checkEspressoDataAvailability rejects. Keep them, but say so. --- op-batcher/batcher/fallback_auth_test.go | 6 +++++- op-batcher/batcher/service_test.go | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/op-batcher/batcher/fallback_auth_test.go b/op-batcher/batcher/fallback_auth_test.go index 980c910bf0b..6b6084163ca 100644 --- a/op-batcher/batcher/fallback_auth_test.go +++ b/op-batcher/batcher/fallback_auth_test.go @@ -79,7 +79,9 @@ func testFallbackTxData() txData { } // testBlobCandidate returns a tx candidate carrying one (zero) blob, which -// routes sendTxWithFallbackAuth onto the serialized blob path. +// routes sendTxWithFallbackAuth onto the serialized blob path. That path is +// unreachable in production while calldata-only DA is enforced; its tests are kept +// so it stays covered if the restriction is lifted. func testBlobCandidate() *txmgr.TxCandidate { return &txmgr.TxCandidate{Blobs: []*eth.Blob{{}}} } @@ -410,6 +412,8 @@ func TestComputeCommitment_Parity(t *testing.T) { } }) + // Same caveat as testBlobCandidate: no live path computes a blob commitment while + // calldata-only DA is enforced. t.Run("blobs", func(t *testing.T) { for _, n := range []int{1, 3} { blobs := make([]*eth.Blob, n) diff --git a/op-batcher/batcher/service_test.go b/op-batcher/batcher/service_test.go index 0de6dd90abd..8f55413be5a 100644 --- a/op-batcher/batcher/service_test.go +++ b/op-batcher/batcher/service_test.go @@ -50,6 +50,10 @@ func TestCheckEspressoDataAvailability(t *testing.T) { // TestCheckFallbackAuthConfirmations: the NumConfirmations headroom bound only // applies when a blob/auto DA batcher can actually emit auth→batch pairs — a // BatchAuthenticator is configured AND the EspressoTime fork is scheduled. +// +// Cases pairing a set espressoTime with a non-calldata DA type describe batchers +// checkEspressoDataAvailability refuses to start. They only occur if the calldata-only +// restriction is lifted, and are kept so the bound stays covered if it is. func TestCheckFallbackAuthConfirmations(t *testing.T) { espressoTime := uint64(0) authAddr := common.Address{0x01} From bc5a440d086600a6cd735fb8d411fbb0181d63ae Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 15:23:47 +0200 Subject: [PATCH 11/12] espresso: cover the factory blob path and a scheduled-but-inactive batcher TestOpenDataDropsBlobsPostEspresso drives NewDataSourceFactory through OpenData and Next with Espresso enforced, asserting a blob inbox tx triggers no blob fetch; the bare MockBlobsFetcher fails the test if one is issued. TestCheckEspressoDataAvailability gains a future espresso_time, the input that distinguishes the batcher's scheduled-or-active gate from an activation check. --- op-batcher/batcher/service_test.go | 10 ++- .../derive/espresso_blob_data_source_test.go | 65 +++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/op-batcher/batcher/service_test.go b/op-batcher/batcher/service_test.go index 8f55413be5a..adcfcabf0c7 100644 --- a/op-batcher/batcher/service_test.go +++ b/op-batcher/batcher/service_test.go @@ -13,10 +13,15 @@ import ( ) // TestCheckEspressoDataAvailability: chains with Espresso scheduled are -// calldata-only (DEC-op-026) — post-Espresso derivation drops blob batches, so a +// calldata-only — post-Espresso derivation drops blob batches, so a // blob/auto DA configuration must be rejected at startup. func TestCheckEspressoDataAvailability(t *testing.T) { espressoTime := uint64(0) + // Far enough out that the fork cannot have activated. The check keys on EspressoTime + // being set rather than on activation, deliberately: derivation only drops blobs from + // activation, so this is the only thing keeping blobs out of the pre-fork blocks a + // post-Espresso proof walks back through. + futureEspressoTime := uint64(1) << 40 tests := []struct { name string @@ -29,6 +34,9 @@ func TestCheckEspressoDataAvailability(t *testing.T) { {"espresso scheduled: calldata allowed", &espressoTime, flags.CalldataType, false}, {"espresso scheduled: blobs rejected", &espressoTime, flags.BlobsType, true}, {"espresso scheduled: auto rejected", &espressoTime, flags.AutoType, true}, + {"espresso scheduled but not active: calldata allowed", &futureEspressoTime, flags.CalldataType, false}, + {"espresso scheduled but not active: blobs rejected", &futureEspressoTime, flags.BlobsType, true}, + {"espresso scheduled but not active: auto rejected", &futureEspressoTime, flags.AutoType, true}, } for _, test := range tests { 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 4e01b4e3df9..fc3a81d3d58 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -3,6 +3,7 @@ package derive import ( "context" "crypto/ecdsa" + "io" "math/big" "math/rand" "testing" @@ -100,7 +101,7 @@ func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1Block // TestDataAndHashesFromTxsEventAuth tests event-based batch authentication in the blob // data source path, and that blob-carrying inbox transactions are dropped post-fork -// regardless of authentication (calldata-only DA, DEC-op-026). +// regardless of authentication (calldata-only DA). // // Event-based authentication is only enforced once the fork has been active for // BatchAuthEnforcementDelaySecs; the fixture activates the fork at L1 origin time 0 @@ -158,7 +159,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { t.Run("authenticated blob tx rejected: blob DA unsupported post-fork", func(t *testing.T) { // Even a fully event-authenticated blob batch must be dropped post-fork: - // batch data is calldata-only (DEC-op-026) because the Celo fault-proof + // batch data is calldata-only because the Celo fault-proof // host cannot supply blob preimages. l1F := &testutils.MockL1Source{} blobHash := testutils.RandomHash(rng) @@ -322,7 +323,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // One calldata batch and one blob batch in the same block, each authenticated // via its own commitment (calldata hash vs blob-hash concatenation). Post-fork // only the calldata batch may pass; the blob batch is dropped despite its - // valid authentication (calldata-only DA, DEC-op-026). + // valid authentication (calldata-only DA). l1F := &testutils.MockL1Source{} calldataTx, _ := types.SignNewTx(privateKey, signer, &types.LegacyTx{ Nonce: rng.Uint64(), @@ -517,7 +518,7 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { t.Run("post-fork: blob batcher tx dropped from activation onward", func(t *testing.T) { // From the moment the fork activates — including the grace window, where // calldata batches still pass on sender auth — blob batches are dropped - // (calldata-only DA, DEC-op-026). The empty mock also asserts no receipt + // (calldata-only DA). The empty mock also asserts no receipt // scanning happens pre-enforcement. l1F := &testutils.MockL1Source{} tx := newBlobBatchTx(t, testutils.RandomHash(rng)) @@ -532,3 +533,59 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { l1F.AssertExpectations(t) }) } + +// TestOpenDataDropsBlobsPostEspresso drives the whole data-source path — NewDataSourceFactory, +// OpenData, Next — rather than calling dataAndHashesFromTxs directly, and asserts that a blob +// inbox tx costs no blob fetch once Espresso is active. The blobs fetcher has no expectations +// set, so any GetBlobsByHash call is unexpected and fails the test; that is what pins the +// no-blobs-to-fetch short-circuit in BlobDataSource.open. +// +// The ref sits past the enforcement delay with a non-zero espresso_time, so the auth event +// scan runs and the drop is exercised on the enforced path rather than the grace window. +func TestOpenDataDropsBlobsPostEspresso(t *testing.T) { + rng := rand.New(rand.NewSource(5555)) + privateKey := testutils.InsecureRandomKey(rng) + batcherAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)) + batchInboxAddr := testutils.RandomAddress(rng) + authenticatorAddr := testutils.RandomAddress(rng) + logger := testlog.Logger(t, log.LvlInfo) + + chainId := new(big.Int).SetUint64(rng.Uint64()) + signer := types.NewPragueSigner(chainId) + + // Ecotone at genesis so OpenData selects the blob source at all; Espresso at 1000. + ecotoneTime := uint64(0) + espressoTime := uint64(1000) + cfg := &rollup.Config{ + L1ChainID: chainId, + BatchInboxAddress: batchInboxAddr, + EcotoneTime: &ecotoneTime, + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + } + + blobTx, err := types.SignNewTx(privateKey, signer, &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batchInboxAddr, + BlobHashes: []common.Hash{testutils.RandomHash(rng)}, + }) + require.NoError(t, err) + + l1F := &testutils.MockL1Source{} + blobsFetcher := &testutils.MockBlobsFetcher{} + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime + BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), types.Transactions{blobTx}, nil) + + ctx := context.Background() + src, err := NewDataSourceFactory(logger, cfg, l1F, blobsFetcher, nil).OpenData(ctx, ref, batcherAddr) + require.NoError(t, err) + + _, err = src.Next(ctx) + require.ErrorIs(t, err, io.EOF, "the blob batch must be dropped, leaving no data to return") + + l1F.AssertExpectations(t) + blobsFetcher.AssertExpectations(t) +} From 46be18cbf79fbd10ae3e381a10b16127ac053c1e Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Tue, 11 Aug 2026 17:00:12 +0200 Subject: [PATCH 12/12] op-node: put the blob tx first in the mixed-block sub-test With the calldata batch first, the sub-test passes whether the gate uses continue or break: the blob tx is last, so a whole-block short-circuit costs nothing observable. Putting the blob tx first makes break swallow the calldata batch behind it and fail. Matches the ordering celo-kona's mirror of this test uses. --- op-node/rollup/derive/espresso_blob_data_source_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 fc3a81d3d58..54f1aebb427 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -324,6 +324,11 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { // via its own commitment (calldata hash vs blob-hash concatenation). Post-fork // only the calldata batch may pass; the blob batch is dropped despite its // valid authentication (calldata-only DA). + // + // The blob tx goes first, ahead of the calldata batch. That ordering is what makes + // the drop's per-transaction scope observable: a whole-block short-circuit (break + // where the gate has continue) would swallow the calldata batch behind it. With the + // calldata batch first, this test passes either way. l1F := &testutils.MockL1Source{} calldataTx, _ := types.SignNewTx(privateKey, signer, &types.LegacyTx{ Nonce: rng.Uint64(), @@ -347,7 +352,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { ComputeBlobBatchHash([]common.Hash{blobHash}), }) - data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx, blobTx}, &config, batcherAddr, l1F, ref, logger) + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{blobTx, calldataTx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) require.Equal(t, 1, len(data), "only the calldata batch may pass post-fork") require.Equal(t, 0, len(blobHashes))