Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions op-batcher/batcher/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ func (c *CLIConfig) Check() error {
if c.FallbackAuthLeadTime <= 0 {
return fmt.Errorf("FallbackAuthLeadTime must be positive: %v", c.FallbackAuthLeadTime)
}
// The auth→batch distance (num-confirmations + inclusion delay) must fit within
// BatchAuthLookbackWindow, so reserve room for the batch to land or the safe head stalls.
const fallbackAuthInclusionReserve = 75 // blocks (~15 min at 12s L1 slots)
if authLookback := derive.BatchAuthLookbackWindow; authLookback < c.TxMgrConfig.NumConfirmations+fallbackAuthInclusionReserve {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate fallback-auth confirmation limit to Espresso configs

When any deployment sets --num-confirmations above 25, this new check rejects the CLI config before the batcher has loaded the rollup config, so it also blocks chains that have no BatchAuthenticatorAddress or Espresso fallback-auth path at all. The lookback bound only matters for sendTxWithFallbackAuth; non-Espresso or pre-Espresso batchers can still safely wait for more confirmations, so this should be applied only when fallback auth is actually configured/active, or after rollup config is available.

Useful? React with 👍 / 👎.

return fmt.Errorf("NumConfirmations (%d) too high for BatchAuthLookbackWindow (%d): need %d blocks of inclusion headroom",
c.TxMgrConfig.NumConfirmations, authLookback, fallbackAuthInclusionReserve)
}
// 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.
Expand Down
7 changes: 7 additions & 0 deletions op-batcher/batcher/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ func TestBatcherConfig(t *testing.T) {
override: func(c *batcher.CLIConfig) { c.FallbackAuthLeadTime = 0 },
errString: "FallbackAuthLeadTime must be positive",
},
{
name: "num-confirmations leaves too little batch auth lookback window",
override: func(c *batcher.CLIConfig) {
c.TxMgrConfig.NumConfirmations = derive.BatchAuthLookbackWindow
},
errString: "too high for BatchAuthLookbackWindow",
},
{
name: "zero TargetNumFrames",
override: func(c *batcher.CLIConfig) { c.TargetNumFrames = 0 },
Expand Down
15 changes: 0 additions & 15 deletions op-batcher/batcher/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,6 @@ type BatchSubmitter struct {
throttleController *throttler.ThrottleController

publishSignal chan pubInfo

// 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. 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
Expand Down Expand Up @@ -537,12 +528,6 @@ func (l *BatchSubmitter) publishingLoop(ctx context.Context, wg *sync.WaitGroup,
}
}

// Wait for all in-flight fallback-auth submissions to complete to prevent
// new transactions being queued. No-op when the rollup is not configured
// with a BatchAuthenticator or when the EspressoTime hardfork has not
// activated.
l.waitForAuthGroup()

// We _must_ wait for all senders on receiptsCh to finish before we can close it.
if err := txQueue.Wait(); err != nil {
if !errors.Is(err, context.Canceled) {
Expand Down
17 changes: 4 additions & 13 deletions op-batcher/batcher/espresso_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,11 @@ import (
"github.com/ethereum-optimism/optimism/op-service/txmgr"
)

// 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() {
l.authGroup.Wait()
}

// dispatchAuthenticatedSendTx routes sendTx through the fallback-batcher
// post-fork auth path, returning true when the tx has been handed off to
// authGroup. Returns false to mean "fall through to the upstream queue.Send
// path" — pre-fork operation and any cancel tx.
// post-fork auth path, returning true when the tx has been fully handled
// (sendTxWithFallbackAuth blocks until the pair is resolved and a receipt has
// been forwarded). Returns false to mean "fall through to the upstream
// queue.Send path" — pre-fork operation and any cancel tx.
//
// The fallback batcher consults isFallbackAuthRequired to gate authentication
// behind the EspressoTime hardfork: pre-fork the verifier accepts plain
Expand Down
46 changes: 19 additions & 27 deletions op-batcher/batcher/fallback_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func computeCommitment(candidate *txmgr.TxCandidate) ([32]byte, error) {
// 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 := newTxRef(txdata, isCancel)
// The auth tx shares the batch txdata's identity (so a failure requeues the right frames)
// but is always a calldata tx. Auth failures must carry its real type: an ErrAlreadyReserved
// receipt labeled with the batch's blob type would make cancelBlockingTx cancel the wrong
// pool, leaving the reserving tx stuck.
authReference := transactionReference
authReference.isBlob = false
authReference.daType = DaTypeCalldata
l.Log.Debug("Sending fallback-authenticated L1 transaction", "txRef", transactionReference)

commitment, err := computeCommitment(candidate)
Expand Down Expand Up @@ -79,43 +86,22 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca
To: &l.RollupConfig.BatchAuthenticatorAddress,
}

// Private buffered channels: queue.Send forwards exactly one receipt to each, so the watcher
// reads exactly once per channel (even on context cancellation, the queue still emits a
// ctx-error receipt). These never reach handleReceipt; only the synthetic receipt below does.
authReceiptCh := make(chan txmgr.TxReceipt[txRef], 1)
batchReceiptCh := make(chan txmgr.TxReceipt[txRef], 1)

l.Log.Debug(
"Sending fallback authenticateBatchInfo transaction",
"txRef", transactionReference,
"commitment", hexutil.Encode(commitment[:]),
"address", l.RollupConfig.BatchAuthenticatorAddress.String(),
)
// Submit the auth tx then the batch tx, in order, on the publishing-loop goroutine so their
// nonces are assigned in submission order. Each Send blocks here when the queue is at its
// MaxPendingTransactions limit.
queue.Send(transactionReference, verifyCandidate, authReceiptCh)
queue.Send(transactionReference, *candidate, batchReceiptCh)

l.authGroup.Add(1)
go func() {
defer l.authGroup.Done()
l.watchFallbackAuthReceipts(transactionReference, authReceiptCh, batchReceiptCh, receiptsCh)
}()
}

// watchFallbackAuthReceipts collects the auth and batch receipts for a fallback-auth pair,
// validates that the batch tx landed within the lookback window of the auth tx, and forwards a
// single synthetic receipt keyed to the batch txData onto receiptsCh. Any failure produces an
// error receipt so the channel manager rewinds and resubmits the frame set.
func (l *BatchSubmitter) watchFallbackAuthReceipts(transactionReference txRef, authReceiptCh, batchReceiptCh chan txmgr.TxReceipt[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) {
// Submit the auth tx and wait for its receipt, then send the batch tx. Each Send
// blocks here when the queue is at its MaxPendingTransactions limit.
authReceiptCh := make(chan txmgr.TxReceipt[txRef], 1)
queue.Send(authReference, verifyCandidate, authReceiptCh)
authResult := <-authReceiptCh
batchResult := <-batchReceiptCh

if authResult.Err != nil {
l.Log.Error("Failed to send fallback authenticateBatchInfo transaction", "txRef", transactionReference, "err", authResult.Err)
receiptsCh <- txmgr.TxReceipt[txRef]{
ID: transactionReference,
ID: authReference,
Err: fmt.Errorf("failed to send fallback authenticateBatchInfo transaction: %w", authResult.Err),
}
return
Expand All @@ -129,12 +115,17 @@ func (l *BatchSubmitter) watchFallbackAuthReceipts(transactionReference txRef, a
if authResult.Receipt.Status != types.ReceiptStatusSuccessful {
l.Log.Error("Fallback authenticateBatchInfo transaction reverted", "txRef", transactionReference, "txHash", authResult.Receipt.TxHash)
receiptsCh <- txmgr.TxReceipt[txRef]{
ID: transactionReference,
ID: authReference,
Err: fmt.Errorf("fallback authenticateBatchInfo transaction reverted: %s", authResult.Receipt.TxHash),
}
return
}

// The auth tx is confirmed on L1. Now send the batch tx
batchReceiptCh := make(chan txmgr.TxReceipt[txRef], 1)
queue.Send(transactionReference, *candidate, batchReceiptCh)
batchResult := <-batchReceiptCh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid waiting for the batch receipt in the publish loop

In post-Espresso fallback-auth mode, dispatchAuthenticatedSendTx is called synchronously from the single publishStateToL1 loop, so this receive keeps the publisher inside sendTransaction until the batch inbox tx has a txmgr receipt. Since txmgr only returns that receipt after the configured confirmation depth, multiple ready frames are now serialized through an auth confirmation plus a batch confirmation cycle, effectively bypassing MaxPendingTransactions and making the batcher publish at most one batch per pair of confirmation waits under load. The batch send only needs to be gated on the auth receipt; after queue.Send for the batch, its receipt should be forwarded without blocking the publisher.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I believe this is now the intended design after addressing m3. See discussion
#458 (comment)


if batchResult.Err != nil {
l.Log.Error("Failed to send batch inbox transaction", "txRef", transactionReference, "err", batchResult.Err)
receiptsCh <- txmgr.TxReceipt[txRef]{
Expand All @@ -147,6 +138,7 @@ func (l *BatchSubmitter) watchFallbackAuthReceipts(transactionReference txRef, a
distance := new(big.Int).Sub(batchResult.Receipt.BlockNumber, authResult.Receipt.BlockNumber)
lookbackWindow := new(big.Int).SetUint64(derive.BatchAuthLookbackWindow)
if distance.Sign() < 0 || distance.Cmp(lookbackWindow) > 0 {
l.Metr.RecordFallbackAuthWindowExceeded()
l.Log.Error("authenticateBatchInfo transaction too far from batch inbox transaction", "txRef", transactionReference, "distance", distance)
receiptsCh <- txmgr.TxReceipt[txRef]{
ID: transactionReference,
Expand Down
85 changes: 75 additions & 10 deletions op-batcher/batcher/fallback_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"

"github.com/ethereum-optimism/optimism/op-batcher/metrics"
"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"
Expand Down Expand Up @@ -41,9 +42,17 @@ func (f *fakeTxSender) Send(id txRef, candidate txmgr.TxCandidate, receiptCh cha
receiptCh <- resp
}

type windowExceededSpy struct {
metrics.Metricer
count int
}

func (s *windowExceededSpy) RecordFallbackAuthWindowExceeded() { s.count++ }

func newFallbackAuthSubmitter(t *testing.T) *BatchSubmitter {
l := &BatchSubmitter{}
l.Log = testlog.Logger(t, log.LevelDebug)
l.Metr = metrics.NoopMetrics
l.RollupConfig = &rollup.Config{
BatchAuthenticatorAddress: common.HexToAddress("0x00000000000000000000000000000000000000aa"),
}
Expand Down Expand Up @@ -80,7 +89,6 @@ func TestFallbackAuth_OrderingAndSuccess(t *testing.T) {
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

require.Len(t, queue.sends, 2)
// First send must target the BatchAuthenticator (the auth tx), giving it the
Expand All @@ -103,18 +111,74 @@ func TestFallbackAuth_AuthFailureRetried(t *testing.T) {

queue := &fakeTxSender{
responses: []txmgr.TxReceipt[txRef]{
{Err: errSendFailed}, // auth fails
{Receipt: receiptWithBlock(101)}, // batch lands anyway
{Err: errSendFailed}, // auth fails. No batch response, it must never be sent
},
}
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)

got := <-receiptsCh
require.Error(t, got.Err)
require.Equal(t, txdata.ID().String(), got.ID.id.String())
// The batch tx must not be sent after an auth failure: it would be crafted at the
// next nonce while the failed auth tx resets the txmgr nonce, leaving a nonce gap.
require.Len(t, queue.sends, 1)
}

// TestFallbackAuth_AuthFailureTxRefType verifies that an auth-tx failure is
// reported under a calldata-typed txRef even when the batch txdata is blob.
// The auth tx is always calldata; if its ErrAlreadyReserved failure were
// labeled with the batch's blob type, cancelBlockingTx would send a calldata
// cancel against a blobpool reservation, which is rejected the same way,
// looping forever without ever displacing the stuck blob tx.
func TestFallbackAuth_AuthFailureTxRefType(t *testing.T) {
l := newFallbackAuthSubmitter(t)
txdata := testFallbackTxData(t)
txdata.daType = DaTypeBlob
candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")}

queue := &fakeTxSender{
responses: []txmgr.TxReceipt[txRef]{
{Err: errSendFailed}, // auth fails. No batch response, it must never be sent
},
}
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

got := <-receiptsCh
require.Error(t, got.Err)
require.Equal(t, txdata.ID().String(), got.ID.id.String())
require.Len(t, queue.sends, 1)
require.False(t, got.ID.isBlob)
require.Equal(t, DaTypeCalldata, got.ID.daType)
}

// TestFallbackAuth_BatchFailureTxRefType verifies the converse: a batch-tx
// failure keeps the batch txdata's own type on the forwarded receipt.
func TestFallbackAuth_BatchFailureTxRefType(t *testing.T) {
l := newFallbackAuthSubmitter(t)
txdata := testFallbackTxData(t)
txdata.daType = DaTypeBlob
candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")}

queue := &fakeTxSender{
responses: []txmgr.TxReceipt[txRef]{
{Receipt: receiptWithBlock(100)}, // auth lands
{Err: errSendFailed}, // batch fails
},
}
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)

got := <-receiptsCh
require.Error(t, got.Err)
require.Equal(t, txdata.ID().String(), got.ID.id.String())
require.Len(t, queue.sends, 2)
require.True(t, got.ID.isBlob)
require.Equal(t, DaTypeBlob, got.ID.daType)
}

func TestFallbackAuth_BatchFailureRetried(t *testing.T) {
Expand All @@ -131,7 +195,6 @@ func TestFallbackAuth_BatchFailureRetried(t *testing.T) {
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

got := <-receiptsCh
require.Error(t, got.Err)
Expand All @@ -148,25 +211,28 @@ func TestFallbackAuth_AuthRevertedRetried(t *testing.T) {

queue := &fakeTxSender{
responses: []txmgr.TxReceipt[txRef]{
{Receipt: revertedReceiptWithBlock(100)}, // auth mined but reverted
{Receipt: receiptWithBlock(101)}, // batch lands
{Receipt: revertedReceiptWithBlock(100)}, // auth mined but reverted. Batch must never be sent
},
}
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

got := <-receiptsCh
require.Error(t, got.Err)
require.Equal(t, txdata.ID().String(), got.ID.id.String())
// A reverted auth tx emits no BatchInfoAuthenticated event, so the batch data would be
// unverifiable so the batch tx must not be submitted at all.
require.Len(t, queue.sends, 1)
}

// TestFallbackAuth_WindowViolationRetried verifies that a batch tx landing
// outside the lookback window of the auth tx produces an error receipt (so the
// channel manager rewinds and resubmits), rather than being confirmed.
func TestFallbackAuth_WindowViolationRetried(t *testing.T) {
l := newFallbackAuthSubmitter(t)
metr := &windowExceededSpy{Metricer: metrics.NoopMetrics}
l.Metr = metr
txdata := testFallbackTxData(t)
candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")}

Expand All @@ -180,11 +246,11 @@ func TestFallbackAuth_WindowViolationRetried(t *testing.T) {
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

got := <-receiptsCh
require.Error(t, got.Err)
require.Equal(t, txdata.ID().String(), got.ID.id.String())
require.Equal(t, 1, metr.count, "window violation should record the fallback_auth_window_exceeded metric")
}

// TestFallbackAuth_WindowBoundaryAccepted pins the inclusive bound of the lookback
Expand All @@ -207,7 +273,6 @@ func TestFallbackAuth_WindowBoundaryAccepted(t *testing.T) {
receiptsCh := make(chan txmgr.TxReceipt[txRef], 1)

l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh)
l.authGroup.Wait()

got := <-receiptsCh
require.NoError(t, got.Err)
Expand Down
18 changes: 15 additions & 3 deletions op-batcher/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type Metricer interface {
RecordBatchDataSizeBytes(daType string, size int)
RecordFailoverToEthDA()

RecordFallbackAuthWindowExceeded()

Document() []opmetrics.DocumentedMetric

PendingDABytes() float64
Expand Down Expand Up @@ -110,9 +112,10 @@ type Metrics struct {
channelOutputBytesTotal prometheus.Counter
channelQueueLength prometheus.Gauge

batchSentDATypeTotal prometheus.CounterVec
batchStoredDataSizeBytesTotal prometheus.CounterVec
altDaFailoverTotal prometheus.Counter
batchSentDATypeTotal prometheus.CounterVec
batchStoredDataSizeBytesTotal prometheus.CounterVec
altDaFailoverTotal prometheus.Counter
fallbackAuthWindowExceededTotal prometheus.Counter

batcherTxEvs opmetrics.EventVec

Expand Down Expand Up @@ -255,6 +258,11 @@ func NewMetrics(procName string) *Metrics {
Name: "alt_da_failover_total",
Help: "Total number of batches that could not be stored in AltDA and were sent to L1 instead",
}),
fallbackAuthWindowExceededTotal: factory.NewCounter(prometheus.CounterOpts{
Namespace: ns,
Name: "fallback_auth_window_exceeded_total",
Help: "Total number of fallback-auth submissions dropped because the batch tx landed beyond BatchAuthLookbackWindow blocks after its auth tx",
}),
blobUsedBytes: factory.NewHistogram(prometheus.HistogramOpts{
Namespace: ns,
Name: "blob_used_bytes",
Expand Down Expand Up @@ -483,6 +491,10 @@ func (m *Metrics) RecordFailoverToEthDA() {
m.altDaFailoverTotal.Inc()
}

func (m *Metrics) RecordFallbackAuthWindowExceeded() {
m.fallbackAuthWindowExceededTotal.Inc()
}

func (m *Metrics) RecordChannelQueueLength(len int) {
m.channelQueueLength.Set(float64(len))
}
Expand Down
1 change: 1 addition & 0 deletions op-batcher/metrics/noop.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func (*noopMetrics) RecordBlobUsedBytes(int) {}
func (*noopMetrics) RecordBatchDaType(string) {}
func (*noopMetrics) RecordBatchDataSizeBytes(string, int) {}
func (*noopMetrics) RecordFailoverToEthDA() {}
func (*noopMetrics) RecordFallbackAuthWindowExceeded() {}

func (*noopMetrics) StartBalanceMetrics(log.Logger, *ethclient.Client, common.Address) io.Closer {
return nil
Expand Down