diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 1aba0d2c83d..1b0f4aa73b9 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" @@ -52,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() }) } @@ -74,7 +85,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 { @@ -129,11 +139,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 @@ -1058,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/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 { 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..3c2c6a31056 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 @@ -27,8 +24,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 @@ -39,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 86254e0c882..61a3de51641 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 @@ -41,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) @@ -96,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 129e7f07cca..0822f8a091f 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" ) @@ -79,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 @@ -109,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) @@ -130,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) @@ -154,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) @@ -179,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) @@ -206,10 +207,45 @@ 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) 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)) + } + }) +}