From 25cbbeedab314b29980aeec1b3491695636f02b2 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 24 Jun 2026 14:01:41 +0200 Subject: [PATCH 1/5] op-batcher: drop unused bind.ContractBackend from L1Client Nothing instantiates a bound contract from L1Client; the fallback-auth path only uses the package-level BatchAuthenticatorMetaData.GetAbi(), which needs no backend. Removing the requirement narrows the interface and lets fakeL1Client drop its nil ContractBackend embed. Co-Authored-By: Claude Opus 4.8 (1M context) --- op-batcher/batcher/driver.go | 2 -- op-batcher/batcher/driver_test.go | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 1aba0d2c83d..5c8cf8498ae 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -12,7 +12,6 @@ import ( "golang.org/x/sync/errgroup" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" @@ -74,7 +73,6 @@ func (r txRef) string(txIDStringer func(txID) string) string { type L1Client interface { HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) - bind.ContractBackend } type L2Client interface { diff --git a/op-batcher/batcher/driver_test.go b/op-batcher/batcher/driver_test.go index 6c50feac001..e5eaf518453 100644 --- a/op-batcher/batcher/driver_test.go +++ b/op-batcher/batcher/driver_test.go @@ -14,8 +14,6 @@ import ( "testing" "time" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - altda "github.com/ethereum-optimism/optimism/op-alt-da" "github.com/ethereum-optimism/optimism/op-batcher/compressor" "github.com/ethereum-optimism/optimism/op-batcher/config" @@ -482,12 +480,7 @@ func TestBatchSubmitter_CriticalError(t *testing.T) { // ======= ALTDA TESTS ======= // fakeL1Client is just a dummy struct. All fault injection is done via the fakeTxMgr (which doesn't interact with this fakeL1Client). -type fakeL1Client struct { - // Embed bind.ContractBackend so the type satisfies the L1Client interface - // (which requires it for the BatchAuthenticator binding used by the - // fallback batcher). AltDA tests never exercise these methods. - bind.ContractBackend -} +type fakeL1Client struct{} func (f *fakeL1Client) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { if number == nil { From e5af1ae843964d832c6316c22ac7ebddbfc52c66 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 24 Jun 2026 14:05:34 +0200 Subject: [PATCH 2/5] op-batcher: compute batch commitment via shared derive helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeCommitment reimplemented the verifier's batch-hash logic, so the two could drift and silently drop post-fork batches. Delegate to derive.ComputeCalldataBatchHash / ComputeBlobBatchHash instead, and add a parity test that checks both paths against those functions — the blob path using the real versioned hashes from txmgr.MakeSidecar (what the verifier reads from tx.BlobHashes()). Co-Authored-By: Claude Opus 4.8 (1M context) --- op-batcher/batcher/fallback_auth.go | 21 +++++++------- op-batcher/batcher/fallback_auth_test.go | 36 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index 86254e0c882..608171b968d 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -4,9 +4,9 @@ import ( "fmt" "math/big" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum-optimism/optimism/espresso/bindings" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" @@ -14,24 +14,25 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" ) -// computeCommitment computes the batch commitment hash from a transaction candidate. -// For calldata transactions, it returns keccak256(calldata). -// For blob transactions, it returns keccak256(concat(blobVersionedHashes)). +// computeCommitment computes the batch commitment hash from a transaction +// candidate. It delegates to the same functions the verifier uses so the two +// provably agree on the bytes that get authenticated: +// - calldata transactions: keccak256(calldata). +// - blob transactions: keccak256(concat(blobVersionedHashes)). func computeCommitment(candidate *txmgr.TxCandidate) ([32]byte, error) { if len(candidate.Blobs) == 0 { - return crypto.Keccak256Hash(candidate.TxData), nil + return derive.ComputeCalldataBatchHash(candidate.TxData), nil } - concatenatedBlobHashes := make([]byte, 0) - for _, blob := range candidate.Blobs { + blobHashes := make([]common.Hash, len(candidate.Blobs)) + for i, blob := range candidate.Blobs { blobCommitment, err := blob.ComputeKZGCommitment() if err != nil { return [32]byte{}, fmt.Errorf("failed to compute KZG commitment for blob: %w", err) } - blobHash := eth.KZGToVersionedHash(blobCommitment) - concatenatedBlobHashes = append(concatenatedBlobHashes, blobHash.Bytes()...) + blobHashes[i] = eth.KZGToVersionedHash(blobCommitment) } - return crypto.Keccak256Hash(concatenatedBlobHashes), nil + return derive.ComputeBlobBatchHash(blobHashes), nil } // sendTxWithFallbackAuth authenticates a batch transaction via the BatchAuthenticator contract diff --git a/op-batcher/batcher/fallback_auth_test.go b/op-batcher/batcher/fallback_auth_test.go index 129e7f07cca..19e0cc1de65 100644 --- a/op-batcher/batcher/fallback_auth_test.go +++ b/op-batcher/batcher/fallback_auth_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) @@ -213,3 +214,38 @@ func TestFallbackAuth_WindowBoundaryAccepted(t *testing.T) { require.Equal(t, receiptWithBlock(boundary).BlockNumber, got.Receipt.BlockNumber) require.Equal(t, txdata.ID().String(), got.ID.id.String()) } + +// TestComputeCommitment_Parity locks the batcher's batch-commitment computation to +// the verifier's. The batcher must hash exactly what op-node derivation hashes, or +// post-fork batches fail the commitment match and are silently dropped. It checks +// both paths against derive.ComputeCalldataBatchHash / derive.ComputeBlobBatchHash; +// for blobs it uses the real versioned hashes the tx will carry (via MakeSidecar, +// the same hashes the verifier reads from tx.BlobHashes()). +func TestComputeCommitment_Parity(t *testing.T) { + t.Run("calldata", func(t *testing.T) { + for _, data := range [][]byte{[]byte("batch calldata payload"), {}, nil} { + got, err := computeCommitment(&txmgr.TxCandidate{TxData: data}) + require.NoError(t, err) + require.Equal(t, derive.ComputeCalldataBatchHash(data), common.Hash(got)) + } + }) + + t.Run("blobs", func(t *testing.T) { + for _, n := range []int{1, 3} { + blobs := make([]*eth.Blob, n) + for i := range blobs { + var blob eth.Blob + // Distinct first byte per blob so the versioned hashes differ and the + // concatenation order is actually exercised. + require.NoError(t, blob.FromData(eth.Data{byte(i), 0xab, 0xcd})) + blobs[i] = &blob + } + _, blobHashes, err := txmgr.MakeSidecar(blobs, false) + require.NoError(t, err) + + got, err := computeCommitment(&txmgr.TxCandidate{Blobs: blobs}) + require.NoError(t, err) + require.Equal(t, derive.ComputeBlobBatchHash(blobHashes), common.Hash(got)) + } + }) +} From 740b5abafa23619a7e8d81b381146ce5c1ce7be8 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 24 Jun 2026 14:21:20 +0200 Subject: [PATCH 3/5] op-batcher: fix inaccurate fallback-auth comments Reference the real verifier gate rollupCfg.IsEspresso(l1OriginTime) instead of the non-existent DataSourceConfig.isEspressoEnforcement, and drop the misleading claim that authenticateBatchInfo is gated because it 'would revert against the default activeIsEspresso=true contract state'. That activeIsEspresso switch is an independent guardian-set mode, not a fork invariant; the verifier-gate reason alone is the correct one. Co-Authored-By: Claude Opus 4.8 (1M context) --- op-batcher/batcher/espresso_active.go | 4 ++-- op-batcher/batcher/espresso_driver.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/op-batcher/batcher/espresso_active.go b/op-batcher/batcher/espresso_active.go index 2a1fedc34e0..943ca6b1b05 100644 --- a/op-batcher/batcher/espresso_active.go +++ b/op-batcher/batcher/espresso_active.go @@ -20,8 +20,8 @@ func (l *BatchSubmitter) hasBatchAuthenticator() bool { // posting to the BatchInbox. // // This decision must align with the verifier's per-L1-block fork gate -// (DataSourceConfig.isEspressoEnforcement, which evaluates the hardfork -// activation predicate against the *containing* L1 block's timestamp). Since +// (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: // diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 8a5c7a54add..67458a5c06b 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -27,8 +27,7 @@ func (l *BatchSubmitter) waitForAuthGroup() { // The fallback batcher consults isFallbackAuthRequired to gate authentication // behind the EspressoTime hardfork: pre-fork the verifier accepts plain // sender-authenticated batches, and the BatchAuthenticator contract is -// irrelevant; calling authenticateBatchInfo pre-fork would also revert against -// the default activeIsEspresso=true contract state. +// irrelevant. func (l *BatchSubmitter) dispatchAuthenticatedSendTx(txdata txData, isCancel bool, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) bool { if isCancel { return false From 7c834e74dfdabc34a0cc0d847effe741cafd59b0 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 24 Jun 2026 14:53:16 +0200 Subject: [PATCH 4/5] op-batcher: use sync.WaitGroup for fallback-auth watchers authGroup was an errgroup.Group whose goroutines always returned nil (failures are reported via receiptsCh, not the group error), so the error branch in waitForAuthGroup was unreachable. Switch to a plain sync.WaitGroup, drop the dead error handling, and correct the field comment: watcher creation is back-pressured by, not hard-bounded by, MaxPendingTransactions. Also document the receipts-loop-outlives-authGroup invariant that keeps the final receiptsCh send from blocking. Co-Authored-By: Claude Opus 4.8 (1M context) --- op-batcher/batcher/driver.go | 11 ++++++----- op-batcher/batcher/espresso_driver.go | 17 +++++++---------- op-batcher/batcher/fallback_auth.go | 7 ++++--- op-batcher/batcher/fallback_auth_test.go | 12 ++++++------ 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 5c8cf8498ae..b237c3d44b2 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -127,11 +127,12 @@ type BatchSubmitter struct { // authGroup tracks the fallback batcher's receipt-watcher goroutines (one // per auth+batch pair) so the publishing loop can drain them via - // waitForAuthGroup before closing receiptsCh. It is intentionally unbounded: - // pending-tx throttling is enforced by the txmgr Queue (queue.Send blocks at - // MaxPendingTransactions), and the live watcher count is derived from the - // queue's in-flight tx count, so it inherits the same bound. - authGroup errgroup.Group + // waitForAuthGroup before closing receiptsCh. New watchers are back-pressured + // (not hard-bounded) by the txmgr Queue: queue.Send blocks at + // MaxPendingTransactions, so watchers are created no faster than txs drain, + // though a slow receipts loop can briefly leave more than that parked on their + // final receiptsCh send. + authGroup sync.WaitGroup } // NewBatchSubmitter initializes the BatchSubmitter driver from a preconfigured DriverSetup diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 67458a5c06b..066ff286d35 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -1,22 +1,19 @@ package batcher import ( - "context" - "errors" "fmt" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) -// waitForAuthGroup blocks until all in-flight fallback-auth submissions have -// completed. Called from publishingLoop's tail; blocks until killCtx is -// cancelled if any auth retries are still in flight. +// waitForAuthGroup blocks until all in-flight fallback-auth watcher goroutines +// have finished. publishingLoop calls it before closing receiptsCh: each watcher +// is a sender on receiptsCh, so the receipts loop must still be draining +// receiptsCh at this point or a watcher's final send would block forever. Each +// watcher always terminates because the txmgr Queue emits exactly one receipt per +// Send, even on context cancellation. func (l *BatchSubmitter) waitForAuthGroup() { - if err := l.authGroup.Wait(); err != nil { - if !errors.Is(err, context.Canceled) { - l.Log.Error("error waiting for fallback-auth transactions to complete", "err", err) - } - } + l.authGroup.Wait() } // dispatchAuthenticatedSendTx routes sendTx through the fallback-batcher diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index 608171b968d..a1e89a6f0d7 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -97,10 +97,11 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca queue.Send(transactionReference, verifyCandidate, authReceiptCh) queue.Send(transactionReference, *candidate, batchReceiptCh) - l.authGroup.Go(func() error { + l.authGroup.Add(1) + go func() { + defer l.authGroup.Done() l.watchFallbackAuthReceipts(transactionReference, authReceiptCh, batchReceiptCh, receiptsCh) - return nil - }) + }() } // watchFallbackAuthReceipts collects the auth and batch receipts for a fallback-auth pair, diff --git a/op-batcher/batcher/fallback_auth_test.go b/op-batcher/batcher/fallback_auth_test.go index 19e0cc1de65..0822f8a091f 100644 --- a/op-batcher/batcher/fallback_auth_test.go +++ b/op-batcher/batcher/fallback_auth_test.go @@ -80,7 +80,7 @@ func TestFallbackAuth_OrderingAndSuccess(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() require.Len(t, queue.sends, 2) // First send must target the BatchAuthenticator (the auth tx), giving it the @@ -110,7 +110,7 @@ func TestFallbackAuth_AuthFailureRetried(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() got := <-receiptsCh require.Error(t, got.Err) @@ -131,7 +131,7 @@ func TestFallbackAuth_BatchFailureRetried(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() got := <-receiptsCh require.Error(t, got.Err) @@ -155,7 +155,7 @@ func TestFallbackAuth_AuthRevertedRetried(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() got := <-receiptsCh require.Error(t, got.Err) @@ -180,7 +180,7 @@ func TestFallbackAuth_WindowViolationRetried(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() got := <-receiptsCh require.Error(t, got.Err) @@ -207,7 +207,7 @@ func TestFallbackAuth_WindowBoundaryAccepted(t *testing.T) { receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - require.NoError(t, l.authGroup.Wait()) + l.authGroup.Wait() got := <-receiptsCh require.NoError(t, got.Err) From fe9726dea86a160171dfa6fbe42c747502b042f5 Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 24 Jun 2026 14:55:02 +0200 Subject: [PATCH 5/5] op-batcher: extract newTxRef helper The txRef literal (id/isCancel/isBlob/daType/size from a txData) was duplicated across sendTx, the fallback-auth gate error path, and the fallback-auth submission. Extract newTxRef(txdata, isCancel) so the three sites stay in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- op-batcher/batcher/driver.go | 14 +++++++++++++- op-batcher/batcher/espresso_driver.go | 2 +- op-batcher/batcher/fallback_auth.go | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index b237c3d44b2..1b0f4aa73b9 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -51,6 +51,18 @@ type txRef struct { size int } +// newTxRef builds the txRef that identifies a batch submission across the txmgr +// queue and receipt handling. +func newTxRef(txdata txData, isCancel bool) txRef { + return txRef{ + id: txdata.ID(), + isCancel: isCancel, + isBlob: txdata.daType == DaTypeBlob, + daType: txdata.daType, + size: txdata.Len(), + } +} + func (r txRef) String() string { return r.string(func(id txID) string { return id.String() }) } @@ -1057,7 +1069,7 @@ func (l *BatchSubmitter) sendTx(txdata txData, isCancel bool, candidate *txmgr.T return } - queue.Send(txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob, daType: txdata.daType, size: txdata.Len()}, *candidate, receiptsCh) + queue.Send(newTxRef(txdata, isCancel), *candidate, receiptsCh) } func (l *BatchSubmitter) blobTxCandidate(data txData) (*txmgr.TxCandidate, error) { diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 066ff286d35..3c2c6a31056 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -35,7 +35,7 @@ func (l *BatchSubmitter) dispatchAuthenticatedSendTx(txdata txData, isCancel boo fallbackAuthRequired, err := l.isFallbackAuthRequired(l.killCtx) if err != nil { receiptsCh <- txmgr.TxReceipt[txRef]{ - ID: txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob, daType: txdata.daType, size: txdata.Len()}, + ID: newTxRef(txdata, isCancel), Err: fmt.Errorf("failed to evaluate fallback-auth gate: %w", err), } return true diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index a1e89a6f0d7..61a3de51641 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -42,7 +42,7 @@ func computeCommitment(candidate *txmgr.TxCandidate) ([32]byte, error) { // The contract's fallback path checks msg.sender against systemConfig.batcherHash(), so no // separate signature is needed — the L1 transaction is already signed by the TxManager's key. func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { - transactionReference := txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob, daType: txdata.daType, size: txdata.Len()} + transactionReference := newTxRef(txdata, isCancel) l.Log.Debug("Sending fallback-authenticated L1 transaction", "txRef", transactionReference) commitment, err := computeCommitment(candidate)