diff --git a/op-node/rollup/derive/altda_data_source_test.go b/op-node/rollup/derive/altda_data_source_test.go index fdf088ab23b..d8fd4cb5fa7 100644 --- a/op-node/rollup/derive/altda_data_source_test.go +++ b/op-node/rollup/derive/altda_data_source_test.go @@ -2,6 +2,7 @@ package derive import ( "context" + "errors" "io" "math/big" "math/rand" @@ -118,12 +119,11 @@ func TestAltDADataSource(t *testing.T) { } l1Refs = append(l1Refs, ref) logger.Info("new l1 block", "ref", ref) - // called for each l1 block to sync challenges - l1F.ExpectFetchReceipts(ref.Hash, nil, types.Receipts{}, nil) // pick a random number of commitments to include in the l1 block c := rng.Intn(4) var txs []*types.Transaction + var receipts types.Receipts for j := 0; j < c; j++ { // mock input commitments in l1 transactions @@ -147,9 +147,17 @@ func TestAltDADataSource(t *testing.T) { }) require.NoError(t, err) + receipt := types.Receipt{ + TxHash: tx.Hash(), + Status: types.ReceiptStatusSuccessful, + } + txs = append(txs, tx) + receipts = append(receipts, &receipt) } + l1F.SetFetchReceipts(ref.Hash, testutils.RandomBlockInfo(rng), receipts, nil) + logger.Info("included commitments", "count", c) l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) // called once per derivation @@ -221,12 +229,11 @@ func TestAltDADataSource(t *testing.T) { } l1Refs = append(l1Refs, ref) logger.Info("new l1 block", "ref", ref) - // called for each l1 block to sync challenges - l1F.ExpectFetchReceipts(ref.Hash, nil, types.Receipts{}, nil) // pick a random number of commitments to include in the l1 block c := rng.Intn(4) var txs []*types.Transaction + var receipts []*types.Receipt for j := 0; j < c; j++ { // mock input commitments in l1 transactions @@ -249,11 +256,18 @@ func TestAltDADataSource(t *testing.T) { }) require.NoError(t, err) + receipt := &types.Receipt{ + TxHash: tx.Hash(), + Status: types.ReceiptStatusSuccessful, + } + txs = append(txs, tx) + receipts = append(receipts, receipt) } logger.Info("included commitments", "count", c) l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) + l1F.SetFetchReceipts(ref.Hash, testutils.RandomBlockInfo(rng), receipts, nil) } // create a new data source for each block @@ -352,7 +366,6 @@ func TestAltDADataSourceStall(t *testing.T) { ParentHash: parent.Hash, Time: parent.Time + l1Time, } - l1F.ExpectFetchReceipts(ref.Hash, nil, types.Receipts{}, nil) // mock input commitments in l1 transactions input := testutils.RandomData(rng, 2000) comm, _ := storage.SetInput(ctx, input) @@ -370,7 +383,9 @@ func TestAltDADataSourceStall(t *testing.T) { require.NoError(t, err) txs := []*types.Transaction{tx} + receipts := types.Receipts{&types.Receipt{TxHash: tx.Hash(), Status: types.ReceiptStatusSuccessful}} + l1F.SetFetchReceipts(ref.Hash, nil, receipts, nil) l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) // delete the input from the DA provider so it returns not found @@ -475,7 +490,6 @@ func TestAltDADataSourceInvalidData(t *testing.T) { ParentHash: parent.Hash, Time: parent.Time + l1Time, } - l1F.ExpectFetchReceipts(ref.Hash, nil, types.Receipts{}, nil) // mock input commitments in l1 transactions with an oversized input input := testutils.RandomData(rng, altda.MaxInputSize+1) comm, _ := storage.SetInput(ctx, input) @@ -522,7 +536,12 @@ func TestAltDADataSourceInvalidData(t *testing.T) { require.NoError(t, err) txs := []*types.Transaction{tx1, tx2, tx3} + receipts := types.Receipts{ + &types.Receipt{TxHash: tx1.Hash(), Status: types.ReceiptStatusSuccessful}, + &types.Receipt{TxHash: tx2.Hash(), Status: types.ReceiptStatusSuccessful}, + &types.Receipt{TxHash: tx3.Hash(), Status: types.ReceiptStatusSuccessful}} + l1F.SetFetchReceipts(ref.Hash, nil, receipts, nil) l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) src, err := factory.OpenData(ctx, ref, batcherAddr) @@ -543,3 +562,118 @@ func TestAltDADataSourceInvalidData(t *testing.T) { l1F.AssertExpectations(t) } + +// TestAltDADataSourceL1FetcherErrors tests that the pipeline handles intermittent errors in +// L1Source correctly. +func TestAltDADataSourceL1FetcherErrors(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + ctx := context.Background() + + rng := rand.New(rand.NewSource(1234)) + + l1F := &testutils.MockL1Source{} + + storage := altda.NewMockDAClient(logger) + + pcfg := altda.Config{ + ChallengeWindow: 90, ResolveWindow: 90, + } + + da := altda.NewAltDAWithStorage(logger, pcfg, storage, &altda.NoopMetrics{}) + + // Create rollup genesis and config + l1Time := uint64(2) + refA := testutils.RandomBlockRef(rng) + refA.Number = 1 + l1Refs := []eth.L1BlockRef{refA} + refA0 := eth.L2BlockRef{ + Hash: testutils.RandomHash(rng), + Number: 0, + ParentHash: common.Hash{}, + Time: refA.Time, + L1Origin: refA.ID(), + SequenceNumber: 0, + } + batcherPriv := testutils.RandomKey() + batcherAddr := crypto.PubkeyToAddress(batcherPriv.PublicKey) + batcherInbox := common.Address{42} + cfg := &rollup.Config{ + Genesis: rollup.Genesis{ + L1: refA.ID(), + L2: refA0.ID(), + L2Time: refA0.Time, + }, + L1ChainID: big.NewInt(1), + BlockTime: 1, + SeqWindowSize: 20, + BatchInboxAddress: batcherInbox, + AltDAConfig: &rollup.AltDAConfig{ + DAChallengeWindow: pcfg.ChallengeWindow, + DAResolveWindow: pcfg.ResolveWindow, + CommitmentType: altda.KeccakCommitmentString, + }, + } + + signer := cfg.L1Signer() + + factory := NewDataSourceFactory(logger, cfg, l1F, nil, da) + + parent := l1Refs[0] + // create a new mock l1 ref + ref := eth.L1BlockRef{ + Hash: testutils.RandomHash(rng), + Number: parent.Number + 1, + ParentHash: parent.Hash, + Time: parent.Time + l1Time, + } + // mock input to include in l1 transaction + input := testutils.RandomData(rng, 200) + comm, _ := storage.SetInput(ctx, input) + + tx, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: signer.ChainID(), + Nonce: 0, + GasTipCap: big.NewInt(2 * params.GWei), + GasFeeCap: big.NewInt(30 * params.GWei), + Gas: 100_000, + To: &batcherInbox, + Value: big.NewInt(int64(0)), + Data: comm.TxData(), + }) + require.NoError(t, err) + + txs := []*types.Transaction{tx} + + // First attempt: InfoAndTxsByHash fails, so CalldataSource opens in closed state. + // Note: the mock panics on nil interface type-assert, so we pass a dummy BlockInfo even for error cases. + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), nil, errors.New("Intermittent error")) + + src, err := factory.OpenData(ctx, ref, batcherAddr) + // Data source should still be opened correctly (error is deferred) + require.NoError(t, err) + + // On Next(), AltDA calls AdvanceL1Origin which fetches receipts for challenge events, + // then the inner CalldataSource retries InfoAndTxsByHash. + + // Second attempt: AdvanceL1Origin needs receipts, then InfoAndTxsByHash fails again. + l1F.ExpectFetchReceipts(ref.Hash, nil, types.Receipts{}, nil) + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), nil, errors.New("Intermittent error")) + + // Should fail because InfoAndTxsByHash still returns error + _, err = src.Next(ctx) + require.Error(t, err) + + // Third attempt: InfoAndTxsByHash succeeds, data is returned. + // AltDA AdvanceL1Origin is a no-op since the origin was already advanced. + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) + + // regular input is passed through + data, err := src.Next(ctx) + require.NoError(t, err) + require.Equal(t, hexutil.Bytes(input), data) + + _, err = src.Next(ctx) + require.ErrorIs(t, err, io.EOF) + + l1F.AssertExpectations(t) +} diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go new file mode 100644 index 00000000000..e2c91de9520 --- /dev/null +++ b/op-node/rollup/derive/batch_authenticator.go @@ -0,0 +1,205 @@ +package derive + +import ( + "context" + "errors" + "fmt" + + lru "github.com/hashicorp/golang-lru/v2" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +var ( + // BatchInfoAuthenticatedABI is the event signature for + // BatchInfoAuthenticated(bytes32 commitment, address indexed caller). + // The commitment is an unindexed (data) argument; only caller is indexed. + BatchInfoAuthenticatedABI = "BatchInfoAuthenticated(bytes32,address)" + BatchInfoAuthenticatedABIHash = crypto.Keccak256Hash([]byte(BatchInfoAuthenticatedABI)) +) + +// BatchAuthCaches holds the LRU caches used by CollectAuthenticatedBatches. +// Keyed by block hash so they are naturally reorg-safe: after a reorg the +// parent-hash traversal follows a different chain and stale entries are +// never hit. Thread-safe via lru.Cache's internal mutex. +type BatchAuthCaches struct { + // AuthCache maps L1 block hash to the set of authenticated batch + // commitments found in that block's receipts, where each commitment maps to + // the caller (the address that emitted the auth event). + AuthCache *lru.Cache[common.Hash, map[common.Hash]common.Address] + // RefCache maps L1 block hash to its L1BlockRef, avoiding redundant + // L1BlockRefByHash RPC calls during lookback window traversal. + RefCache *lru.Cache[common.Hash, eth.L1BlockRef] +} + +// NewBatchAuthCaches creates caches sized for the BatchAuthLookbackWindow. +func NewBatchAuthCaches() *BatchAuthCaches { + // The lookback window covers 101 blocks (the ref block plus 100 ancestors), + // so 101 entries are live during any single traversal. We add +2 (not +1) + // because the traversal reads newest-to-oldest: the ref block is touched + // first and so becomes the LRU entry. With exactly 101 slots, inserting the + // next block's ref would evict the previous ref (its parent) — the very block + // we're about to read — triggering a cascade of evict-and-refetch through the + // whole window. The extra slot leaves room for the 101 new window entries plus + // one stale entry (the block that just fell out of the lookback window). That + // stale entry, untouched in the current traversal, is the LRU and gets evicted + // instead, so no cascade occurs. + // lru.New only errors on size <= 0. + size := int(BatchAuthLookbackWindow) + 2 + authCache, _ := lru.New[common.Hash, map[common.Hash]common.Address](size) + refCache, _ := lru.New[common.Hash, eth.L1BlockRef](size) + return &BatchAuthCaches{AuthCache: authCache, RefCache: refCache} +} + +// ComputeCalldataBatchHash computes keccak256(calldata), matching the BatchAuthenticator +// contract's calldata batch validation path. +func ComputeCalldataBatchHash(data []byte) common.Hash { + return crypto.Keccak256Hash(data) +} + +// ComputeBlobBatchHash computes keccak256(concat(blobHashes)), matching the BatchAuthenticator +// contract's blob batch validation path. +func ComputeBlobBatchHash(blobHashes []common.Hash) common.Hash { + concatenated := make([]byte, 32*len(blobHashes)) + for i, h := range blobHashes { + copy(concatenated[i*32:(i+1)*32], h[:]) + } + return crypto.Keccak256Hash(concatenated) +} + +// collectAuthEventsFromReceipts extracts all authenticated batch commitments from +// the given receipts, mapping each commitment to the caller that emitted the +// BatchInfoAuthenticated event (the indexed Topics[1]). The caller is later +// matched against the batch transaction's L1 sender, so a batch is only accepted +// if the same address both authenticated and submitted it. +func collectAuthEventsFromReceipts(receipts types.Receipts, authenticatorAddr common.Address) map[common.Hash]common.Address { + result := make(map[common.Hash]common.Address) + for _, receipt := range receipts { + if receipt.Status != types.ReceiptStatusSuccessful { + continue + } + for _, lg := range receipt.Logs { + if lg.Address != authenticatorAddr { + continue + } + if len(lg.Topics) >= 2 && lg.Topics[0] == BatchInfoAuthenticatedABIHash && len(lg.Data) >= 32 { + commitment := common.BytesToHash(lg.Data[:32]) + caller := common.BytesToAddress(lg.Topics[1][:]) + result[commitment] = caller + } + } + } + return result +} + +// CollectAuthenticatedBatches scans L1 receipts in the range +// [ref.Number - lookbackWindow, ref.Number] and returns a map from each batch +// commitment hash that was authenticated via a BatchInfoAuthenticated event to +// the caller that emitted it (the event's indexed `caller`). Callers use this to +// require that a batch transaction's L1 sender matches the address that +// authenticated the batch. +// +// This is called once per L1 block by the data source, and the returned set is checked +// against each candidate batch transaction. This avoids rescanning the lookback window +// for every individual batch transaction. +// +// The scan walks newest block to oldest; when the same commitment is authenticated +// in more than one block, the newest event's caller is retained. +// +// Results are cached per block hash in the provided BatchAuthCaches. For consecutive +// L1 blocks the lookback windows overlap by ~99 blocks, so only one new block's +// receipts need to be fetched on each call. The cache is keyed by block hash (not +// number) so it is naturally reorg-safe. +// +// Using event scanning (rather than L1 contract state reads) keeps the derivation +// pipeline compatible with the op-program fault proof environment, which can only +// access L1 block headers, transactions, receipts, and blobs. +func CollectAuthenticatedBatches( + ctx context.Context, + fetcher L1Fetcher, + ref eth.L1BlockRef, + authenticatorAddr common.Address, + caches *BatchAuthCaches, + logger log.Logger, +) (map[common.Hash]common.Address, error) { + cache := caches.AuthCache + refCache := caches.RefCache + + // Cache the starting block ref so future calls that traverse through this + // block (as part of their lookback window) can resolve it without an RPC call. + refCache.Add(ref.Hash, ref) + + // Traversal is newest-block-first, so a commitment already in the map was + // seen in a newer block; mergeNewest keeps that newer caller (see doc above). + allAuthenticated := make(map[common.Hash]common.Address) + mergeNewest := func(src map[common.Hash]common.Address) { + for commitment, caller := range src { + if _, seen := allAuthenticated[commitment]; !seen { + allAuthenticated[commitment] = caller + } + } + } + + currentBlock := ref + receiptCacheHits := 0 + refCacheHits := 0 + + for { + // Check receipt cache first + if cached, ok := cache.Get(currentBlock.Hash); ok { + mergeNewest(cached) + receiptCacheHits++ + } else { + // Cache miss: fetch receipts, extract events, cache the result + _, receipts, err := fetcher.FetchReceipts(ctx, currentBlock.Hash) + if errors.Is(err, ethereum.NotFound) { + // A block in the lookback window is no longer available, e.g. an L1 + // reorg orphaned it. Treat this like the data sources treat a missing + // ref block (calldata_source.go / blob_data_source.go): signal a reset so + // the pipeline re-derives from a canonical origin, rather than retrying + // the same step forever as a temporary error. + return nil, NewResetError(fmt.Errorf("batch auth: receipts for block %d not found: %w", currentBlock.Number, err)) + } else if err != nil { + return nil, NewTemporaryError(fmt.Errorf("batch auth: failed to fetch receipts for block %d: %w", currentBlock.Number, err)) + } + events := collectAuthEventsFromReceipts(receipts, authenticatorAddr) + cache.Add(currentBlock.Hash, events) + mergeNewest(events) + } + + if currentBlock.Number == 0 || ref.Number-currentBlock.Number >= BatchAuthLookbackWindow { + break + } + + // Resolve parent block ref, using the cache to avoid redundant RPC calls. + // Consecutive L1 blocks share ~99 blocks in their lookback windows, so + // after the first full traversal almost every parent lookup is a cache hit. + parentHash := currentBlock.ParentHash + if cachedRef, ok := refCache.Get(parentHash); ok { + currentBlock = cachedRef + refCacheHits++ + } else { + parentRef, err := fetcher.L1BlockRefByHash(ctx, parentHash) + if errors.Is(err, ethereum.NotFound) { + // See the FetchReceipts NotFound case above: a missing ancestor means the + // lookback window crossed a reorg, so reset rather than retry forever. + return nil, NewResetError(fmt.Errorf("batch auth: L1 block ref %s not found: %w", parentHash.String(), err)) + } else if err != nil { + return nil, NewTemporaryError(fmt.Errorf("batch auth: failed to fetch L1 block ref %s: %w", parentHash.String(), err)) + } + refCache.Add(parentHash, parentRef) + currentBlock = parentRef + } + } + + logger.Debug("collected authenticated batches from lookback window", + "count", len(allAuthenticated), "fromBlock", currentBlock.Number, "toBlock", ref.Number, + "receiptCacheHits", receiptCacheHits, "refCacheHits", refCacheHits) + return allAuthenticated, nil +} diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index d4ec55fa1b2..539b432f948 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -27,13 +27,13 @@ type BlobDataSource struct { ref eth.L1BlockRef batcherAddr common.Address dsCfg DataSourceConfig - fetcher L1TransactionFetcher + fetcher L1Fetcher blobsFetcher L1BlobsFetcher log log.Logger } // NewBlobDataSource creates a new blob data source. -func NewBlobDataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConfig, fetcher L1TransactionFetcher, blobsFetcher L1BlobsFetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter { +func NewBlobDataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConfig, fetcher L1Fetcher, blobsFetcher L1BlobsFetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter { return &BlobDataSource{ ref: ref, dsCfg: dsCfg, @@ -86,7 +86,10 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { return nil, NewTemporaryError(fmt.Errorf("failed to open blob data source: %w", err)) } - data, hashes := dataAndHashesFromTxs(txs, &ds.dsCfg, ds.batcherAddr, ds.log) + data, hashes, err := dataAndHashesFromTxs(ctx, txs, &ds.dsCfg, ds.batcherAddr, ds.fetcher, ds.ref, ds.log) + if err != nil { + return nil, err + } if len(hashes) == 0 { // there are no blobs to fetch so we can return immediately @@ -115,14 +118,52 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // dataAndHashesFromTxs extracts calldata and datahashes from the input transactions and returns them. It // creates a placeholder blobOrCalldata element for each returned blob hash that must be populated // by fillBlobPointers after blob bodies are retrieved. -func dataAndHashesFromTxs(txs types.Transactions, config *DataSourceConfig, batcherAddr common.Address, logger log.Logger) ([]blobOrCalldata, []common.Hash) { +// +// Pre-Espresso (the L1 origin time of `ref` is < *EspressoTime, or unset), +// this runs upstream Optimism semantics: filter by batch inbox + sender == +// batcher. +// +// Post-Espresso, it collects all authenticated batch hashes from a lookback +// window once and rejects any batch whose commitment hash is not in the +// authenticated set. For blob transactions, the batch hash is computed from +// the concatenated blob versioned hashes. +func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *DataSourceConfig, batcherAddr common.Address, fetcher L1Fetcher, ref eth.L1BlockRef, logger log.Logger) ([]blobOrCalldata, []common.Hash, error) { + // Only collect authenticated batch commitments when the Espresso fork is + // active at the L1 origin time of the block we're scanning. Pre-fork, the + // upstream sender-based authorization path is used and authenticatedHashes + // is unused. + var authenticatedHashes map[common.Hash]common.Address + if config.rollupCfg.IsEspresso(ref.Time) { + var err error + authenticatedHashes, err = CollectAuthenticatedBatches( + ctx, fetcher, ref, config.rollupCfg.BatchAuthenticatorAddress, config.batchAuthCaches, logger, + ) + if err != nil { + return nil, nil, err + } + } + data := []blobOrCalldata{} var hashes []common.Hash for _, tx := range txs { - // skip any non-batcher transactions - if !isValidBatchTx(tx, config.l1Signer, config.batchInboxAddress, batcherAddr, logger) { + // skip any non-batcher transactions (wrong type or wrong To address) + if !isValidBatchTx(tx, config.batchInboxAddress, logger) { continue } + + // Compute batch hash depending on tx type + var batchHash common.Hash + if tx.Type() == types.BlobTxType { + batchHash = ComputeBlobBatchHash(tx.BlobHashes()) + } else { + batchHash = ComputeCalldataBatchHash(tx.Data()) + } + + // Check authorization (sender-based pre-fork; event-based post-fork). + if !isBatchTxAuthorized(tx, *config, batcherAddr, batchHash, authenticatedHashes, ref.Time, logger) { + continue + } + // handle non-blob batcher transactions by extracting their calldata if tx.Type() != types.BlobTxType { calldata := eth.Data(tx.Data()) @@ -138,7 +179,7 @@ func dataAndHashesFromTxs(txs types.Transactions, config *DataSourceConfig, batc data = append(data, blobOrCalldata{nil, nil}) // will fill in blob pointers after we download them below } } - return data, hashes + return data, hashes, nil } // fillBlobPointers goes back through the data array and fills in the pointers to the fetched blob diff --git a/op-node/rollup/derive/blob_data_source_test.go b/op-node/rollup/derive/blob_data_source_test.go index a20205544c4..2c07af0fb9d 100644 --- a/op-node/rollup/derive/blob_data_source_test.go +++ b/op-node/rollup/derive/blob_data_source_test.go @@ -1,7 +1,9 @@ package derive import ( + "context" "crypto/ecdsa" + "io" "math/big" "math/rand" "testing" @@ -9,12 +11,16 @@ import ( "github.com/stretchr/testify/require" "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/go-ethereum/params" + "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum-optimism/optimism/op-service/txmgr" "github.com/ethereum/go-ethereum/log" ) @@ -34,6 +40,9 @@ func TestDataAndHashesFromTxs(t *testing.T) { batchInboxAddress: batchInboxAddr, } + ctx := context.Background() + ref := eth.L1BlockRef{Number: 1} + // create a valid non-blob batcher transaction and make sure it's picked up txData := &types.LegacyTx{ Nonce: rng.Uint64(), @@ -45,7 +54,9 @@ func TestDataAndHashesFromTxs(t *testing.T) { } calldataTx, _ := types.SignNewTx(privateKey, signer, txData) txs := types.Transactions{calldataTx} - data, blobHashes := dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + // Legacy mode: no L1Fetcher needed (sender check is local) + data, blobHashes, err := dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 1, len(data)) require.Equal(t, 0, len(blobHashes)) @@ -60,14 +71,16 @@ func TestDataAndHashesFromTxs(t *testing.T) { } blobTx, _ := types.SignNewTx(privateKey, signer, blobTxData) txs = types.Transactions{blobTx} - data, blobHashes = dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + data, blobHashes, err = dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 1, len(data)) require.Equal(t, 1, len(blobHashes)) require.Nil(t, data[0].calldata) // try again with both the blob & calldata transactions and make sure both are picked up txs = types.Transactions{blobTx, calldataTx} - data, blobHashes = dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + data, blobHashes, err = dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 2, len(data)) require.Equal(t, 1, len(blobHashes)) require.NotNil(t, data[1].calldata) @@ -75,7 +88,8 @@ func TestDataAndHashesFromTxs(t *testing.T) { // make sure blob tx to the batch inbox is ignored if not signed by the batcher blobTx, _ = types.SignNewTx(testutils.RandomKey(), signer, blobTxData) txs = types.Transactions{blobTx} - data, blobHashes = dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + data, blobHashes, err = dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 0, len(data)) require.Equal(t, 0, len(blobHashes)) @@ -84,7 +98,8 @@ func TestDataAndHashesFromTxs(t *testing.T) { blobTxData.To = testutils.RandomAddress(rng) blobTx, _ = types.SignNewTx(privateKey, signer, blobTxData) txs = types.Transactions{blobTx} - data, blobHashes = dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + data, blobHashes, err = dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 0, len(data)) require.Equal(t, 0, len(blobHashes)) @@ -98,7 +113,8 @@ func TestDataAndHashesFromTxs(t *testing.T) { setCodeTx, err := types.SignNewTx(privateKey, signer, setCodeTxData) require.NoError(t, err) txs = types.Transactions{setCodeTx} - data, blobHashes = dataAndHashesFromTxs(txs, &config, batcherAddr, logger) + data, blobHashes, err = dataAndHashesFromTxs(ctx, txs, &config, batcherAddr, nil, ref, logger) + require.NoError(t, err) require.Equal(t, 0, len(data)) require.Equal(t, 0, len(blobHashes)) } @@ -152,3 +168,110 @@ func TestFillBlobPointers(t *testing.T) { require.Equal(t, calldataLen, calldataCount) } } + +// TestBlobDataSourceL1FetcherErrors tests that BlobDataSource handles intermittent errors in +// L1Source correctly. +func TestBlobDataSourceL1FetcherErrors(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + ctx := context.Background() + + rng := rand.New(rand.NewSource(1234)) + + l1F := &testutils.MockL1Source{} + blobF := &testutils.MockBlobsFetcher{} + + // Create rollup genesis and config + l1Time := uint64(2) + refA := testutils.RandomBlockRef(rng) + refA.Number = 1 + l1Refs := []eth.L1BlockRef{refA} + refA0 := eth.L2BlockRef{ + Hash: testutils.RandomHash(rng), + Number: 0, + ParentHash: common.Hash{}, + Time: refA.Time, + L1Origin: refA.ID(), + SequenceNumber: 0, + } + batcherPriv := testutils.RandomKey() + batcherAddr := crypto.PubkeyToAddress(batcherPriv.PublicKey) + batcherInbox := common.Address{42} + cfg := &rollup.Config{ + Genesis: rollup.Genesis{ + L1: refA.ID(), + L2: refA0.ID(), + L2Time: refA0.Time, + }, + L1ChainID: big.NewInt(1), + BlockTime: 1, + SeqWindowSize: 20, + BatchInboxAddress: batcherInbox, + EcotoneTime: new(uint64), + } + + signer := cfg.L1Signer() + + factory := NewDataSourceFactory(logger, cfg, l1F, blobF, nil) + + parent := l1Refs[0] + // create a new mock l1 ref + ref := eth.L1BlockRef{ + Hash: testutils.RandomHash(rng), + Number: parent.Number + 1, + ParentHash: parent.Hash, + Time: parent.Time + l1Time, + } + + input := testutils.RandomData(rng, 200) + tx, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: signer.ChainID(), + Nonce: 0, + GasTipCap: big.NewInt(2 * params.GWei), + GasFeeCap: big.NewInt(30 * params.GWei), + Gas: 100_000, + To: &batcherInbox, + Value: big.NewInt(int64(0)), + Data: input, + }) + require.NoError(t, err) + + blobInput := testutils.RandomData(rng, 1024) + blob := new(eth.Blob) + err = blob.FromData(blobInput) + require.NoError(t, err) + _, blobHashes, err := txmgr.MakeSidecar([]*eth.Blob{blob}, false) + require.NoError(t, err) + blobTxData := &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batcherInbox, + Data: testutils.RandomData(rng, rng.Intn(1000)), + BlobHashes: blobHashes, + } + blobTx, _ := types.SignNewTx(batcherPriv, signer, blobTxData) + + txs := []*types.Transaction{tx, blobTx} + + // Open with valid txs — should succeed and fetch blobs + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), txs, nil) + blobF.ExpectOnGetBlobsByHash(ctx, ref.Time, []common.Hash{blobHashes[0]}, []*eth.Blob{(*eth.Blob)(blob)}, nil) + + src, err := factory.OpenData(ctx, ref, batcherAddr) + require.IsType(t, &BlobDataSource{}, src, src) + require.NoError(t, err) + + // calldata input is passed through + data, err := src.Next(ctx) + require.NoError(t, err) + require.Equal(t, hexutil.Bytes(input), data) + + // blob input is passed through + data, err = src.Next(ctx) + require.NoError(t, err) + require.Equal(t, hexutil.Bytes(blobInput), data) + + _, err = src.Next(ctx) + require.ErrorIs(t, err, io.EOF) + + l1F.AssertExpectations(t) +} diff --git a/op-node/rollup/derive/calldata_source.go b/op-node/rollup/derive/calldata_source.go index 0e8147261e9..2803301378e 100644 --- a/op-node/rollup/derive/calldata_source.go +++ b/op-node/rollup/derive/calldata_source.go @@ -24,7 +24,7 @@ type CalldataSource struct { // Required to re-attempt fetching ref eth.L1BlockRef dsCfg DataSourceConfig - fetcher L1TransactionFetcher + fetcher L1Fetcher log log.Logger batcherAddr common.Address @@ -32,21 +32,27 @@ type CalldataSource struct { // NewCalldataSource creates a new calldata source. It suppresses errors in fetching the L1 block if they occur. // If there is an error, it will attempt to fetch the result on the next call to `Next`. -func NewCalldataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConfig, fetcher L1TransactionFetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter { +func NewCalldataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConfig, fetcher L1Fetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter { + closedSource := &CalldataSource{ + open: false, + ref: ref, + dsCfg: dsCfg, + fetcher: fetcher, + log: log, + batcherAddr: batcherAddr, + } + _, txs, err := fetcher.InfoAndTxsByHash(ctx, ref.Hash) if err != nil { - return &CalldataSource{ - open: false, - ref: ref, - dsCfg: dsCfg, - fetcher: fetcher, - log: log, - batcherAddr: batcherAddr, - } + return closedSource + } + data, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, txs, fetcher, ref, log.New("origin", ref)) + if err != nil { + return closedSource } return &CalldataSource{ open: true, - data: DataFromEVMTransactions(dsCfg, batcherAddr, txs, log.New("origin", ref)), + data: data, } } @@ -55,14 +61,17 @@ func NewCalldataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConf // otherwise it returns a temporary error if fetching the block returns an error. func (ds *CalldataSource) Next(ctx context.Context) (eth.Data, error) { if !ds.open { - if _, txs, err := ds.fetcher.InfoAndTxsByHash(ctx, ds.ref.Hash); err == nil { - ds.open = true - ds.data = DataFromEVMTransactions(ds.dsCfg, ds.batcherAddr, txs, ds.log) - } else if errors.Is(err, ethereum.NotFound) { + _, txs, err := ds.fetcher.InfoAndTxsByHash(ctx, ds.ref.Hash) + if errors.Is(err, ethereum.NotFound) { return nil, NewResetError(fmt.Errorf("failed to open calldata source: %w", err)) - } else { + } else if err != nil { return nil, NewTemporaryError(fmt.Errorf("failed to open calldata source: %w", err)) } + ds.data, err = DataFromEVMTransactions(ctx, ds.dsCfg, ds.batcherAddr, txs, ds.fetcher, ds.ref, ds.log) + if err != nil { + return nil, err + } + ds.open = true } if len(ds.data) == 0 { return nil, io.EOF @@ -76,12 +85,42 @@ func (ds *CalldataSource) Next(ctx context.Context) (eth.Data, error) { // DataFromEVMTransactions filters all of the transactions and returns the calldata from transactions // that are sent to the batch inbox address from the batch sender address. // This will return an empty array if no valid transactions are found. -func DataFromEVMTransactions(dsCfg DataSourceConfig, batcherAddr common.Address, txs types.Transactions, log log.Logger) []eth.Data { +// +// Pre-Espresso (the L1 origin time of `ref` is < *EspressoTime, or unset), +// this runs upstream Optimism semantics: filter by batch inbox + sender == +// batcher. +// +// Post-Espresso, 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 DataFromEVMTransactions(ctx context.Context, dsCfg DataSourceConfig, batcherAddr common.Address, txs types.Transactions, fetcher L1Fetcher, ref eth.L1BlockRef, log log.Logger) ([]eth.Data, error) { + // Only collect authenticated batch commitments when the Espresso fork is + // active at the L1 origin time of the block we're scanning. Pre-fork, the + // upstream sender-based authorization path inside isBatchTxAuthorized is used + // and the authenticatedHashes map is unused. + var authenticatedHashes map[common.Hash]common.Address + if dsCfg.rollupCfg.IsEspresso(ref.Time) { + var err error + authenticatedHashes, err = CollectAuthenticatedBatches( + ctx, fetcher, ref, dsCfg.rollupCfg.BatchAuthenticatorAddress, dsCfg.batchAuthCaches, log, + ) + if err != nil { + return nil, err + } + } + out := []eth.Data{} for _, tx := range txs { - if isValidBatchTx(tx, dsCfg.l1Signer, dsCfg.batchInboxAddress, batcherAddr, log) { - out = append(out, tx.Data()) + if !isValidBatchTx(tx, dsCfg.batchInboxAddress, log) { + continue } + + batchHash := ComputeCalldataBatchHash(tx.Data()) + if !isBatchTxAuthorized(tx, dsCfg, batcherAddr, batchHash, authenticatedHashes, ref.Time, log) { + continue + } + + out = append(out, tx.Data()) } - return out + return out, nil } diff --git a/op-node/rollup/derive/calldata_source_test.go b/op-node/rollup/derive/calldata_source_test.go index 01b2616cca3..4d6a854155e 100644 --- a/op-node/rollup/derive/calldata_source_test.go +++ b/op-node/rollup/derive/calldata_source_test.go @@ -1,6 +1,7 @@ package derive import ( + "context" "crypto/ecdsa" "math/big" "math/rand" @@ -115,14 +116,24 @@ func TestDataFromEVMTransactions(t *testing.T) { var expectedData []eth.Data var txs []*types.Transaction for i, tx := range tc.txs { - txs = append(txs, tx.Create(t, signer, rng)) + transaction := tx.Create(t, signer, rng) + txs = append(txs, transaction) + if tx.good { expectedData = append(expectedData, txs[i].Data()) } } - out := DataFromEVMTransactions(DataSourceConfig{cfg.L1Signer(), cfg.BatchInboxAddress, false}, batcherAddr, txs, testlog.Logger(t, log.LevelCrit)) + // Legacy mode (no batch authenticator, Espresso inactive) — uses sender-based auth + dsCfg := DataSourceConfig{ + l1Signer: cfg.L1Signer(), + batchInboxAddress: cfg.BatchInboxAddress, + rollupCfg: cfg, + } + ref := eth.L1BlockRef{Number: 1} + // In legacy mode, no L1Fetcher calls are needed for auth (sender check is local) + out, err := DataFromEVMTransactions(context.Background(), dsCfg, batcherAddr, txs, nil, ref, testlog.Logger(t, log.LevelCrit)) + require.NoError(t, err) require.ElementsMatch(t, expectedData, out) } - } diff --git a/op-node/rollup/derive/data_source.go b/op-node/rollup/derive/data_source.go index d90cfb42ff5..3d659250379 100644 --- a/op-node/rollup/derive/data_source.go +++ b/op-node/rollup/derive/data_source.go @@ -48,10 +48,16 @@ type DataSourceFactory struct { } func NewDataSourceFactory(log log.Logger, cfg *rollup.Config, fetcher L1Fetcher, blobsFetcher L1BlobsFetcher, altDAFetcher AltDAInputFetcher) *DataSourceFactory { + var caches *BatchAuthCaches + if cfg.EspressoTime != nil { + caches = NewBatchAuthCaches() + } config := DataSourceConfig{ l1Signer: cfg.L1Signer(), batchInboxAddress: cfg.BatchInboxAddress, altDAEnabled: cfg.AltDAEnabled(), + rollupCfg: cfg, + batchAuthCaches: caches, } return &DataSourceFactory{ log: log, @@ -64,6 +70,13 @@ func NewDataSourceFactory(log log.Logger, cfg *rollup.Config, fetcher L1Fetcher, } // OpenData returns the appropriate data source for the L1 block `ref`. +// +// The Espresso gate is evaluated against the L1 origin time (ref.Time), +// mirroring the upstream pattern used for ecotoneTime: the data-source layer +// is per-L1-block, so it gates on L1 time. The fork timestamp itself is +// conceptually an L2 timestamp but the per-L1-block decision is stable as +// long as L1 origin time and L2 block time are within MaxSequencerDrift of +// each other (always true on a healthy chain). func (ds *DataSourceFactory) OpenData(ctx context.Context, ref eth.L1BlockRef, batcherAddr common.Address) (DataIter, error) { // Creates a data iterator from blob or calldata source so we can forward it to the altDA source // if enabled as it still requires an L1 data source for fetching input commmitments. @@ -88,13 +101,22 @@ type DataSourceConfig struct { l1Signer types.Signer batchInboxAddress common.Address altDAEnabled bool + // rollupCfg provides Espresso-specific configuration (EspressoTime, + // BatchAuthenticatorAddress) consulted when post-Espresso event-based + // batch authentication is active. + rollupCfg *rollup.Config + // batchAuthCaches holds the LRU caches for batch authentication lookback + // window traversal. Nil when Espresso is not configured. + batchAuthCaches *BatchAuthCaches } -// isValidBatchTx returns true if: +// isValidBatchTx checks basic transaction validity for batch submission: // 1. the transaction type is any of Legacy, ACL, DynamicFee, Blob, or Deposit (for L3s). -// 2. the transaction has a To() address that matches the batch inbox address, and -// 3. the transaction has a valid signature from the batcher address -func isValidBatchTx(tx *types.Transaction, l1Signer types.Signer, batchInboxAddr, batcherAddr common.Address, logger log.Logger) bool { +// 2. the transaction has a To() address that matches the batch inbox address +// +// It does NOT check authentication (sender or event-based) — that is handled separately +// by isBatchTxAuthorized. +func isValidBatchTx(tx *types.Transaction, batchInboxAddr common.Address, logger log.Logger) bool { // For now, we want to disallow the SetCodeTx type or any future types. if tx.Type() > types.BlobTxType && tx.Type() != types.DepositTxType { return false @@ -104,14 +126,76 @@ func isValidBatchTx(tx *types.Transaction, l1Signer types.Signer, batchInboxAddr if to == nil || *to != batchInboxAddr { return false } - seqDataSubmitter, err := l1Signer.Sender(tx) // optimization: only derive sender if To is correct + + return true +} + +// isAuthorizedBatchSender performs upstream-style sender-based authorization: it +// recovers the L1 sender of the transaction and checks it matches the configured +// batcher address. This is the pre-Espresso authorization path. +func isAuthorizedBatchSender(tx *types.Transaction, l1Signer types.Signer, batcherAddr common.Address, logger log.Logger) bool { + sender, err := l1Signer.Sender(tx) if err != nil { logger.Warn("tx in inbox with invalid signature", "hash", tx.Hash(), "err", err) return false } - // some random L1 user might have sent a transaction to our batch inbox, ignore them - if seqDataSubmitter != batcherAddr { - logger.Warn("tx in inbox with unauthorized submitter", "addr", seqDataSubmitter, "hash", tx.Hash(), "err", err) + if sender != batcherAddr { + logger.Warn("tx in inbox with unauthorized submitter", "addr", sender, "hash", tx.Hash()) + return false + } + return true +} + +// isBatchTxAuthorized determines whether a batch transaction is authorized for inclusion. +// +// The fork gate is evaluated against the L1 origin time of the enclosing L1 +// block (passed as l1OriginTime), mirroring the data-source layer's ecotoneTime +// treatment. +// +// Pre-Espresso (l1OriginTime < *EspressoTime, or unset): +// +// upstream behavior — the L1 sender of the transaction must match the configured +// batcher address. The authenticatedHashes map is unused. +// +// Post-Espresso: +// +// the batch's commitment hash must appear in authenticatedHashes (i.e. a +// BatchInfoAuthenticated event was emitted for this commitment within the +// derivation pipeline's lookback window) AND the L1 sender of the batch +// transaction must equal the caller that emitted that event. This binds each +// batch to the address that authenticated it, so a batch authenticated by one +// batcher cannot be submitted by another. Sender-based-only authorization is +// rejected. +func isBatchTxAuthorized( + tx *types.Transaction, + dsCfg DataSourceConfig, + batcherAddr common.Address, + batchHash common.Hash, + authenticatedHashes map[common.Hash]common.Address, + l1OriginTime uint64, + logger log.Logger, +) bool { + if !dsCfg.rollupCfg.IsEspresso(l1OriginTime) { + // Pre-fork: upstream sender-based authorization. + return isAuthorizedBatchSender(tx, dsCfg.l1Signer, batcherAddr, logger) + } + // Post-fork: the commitment must have been authenticated within the lookback window. + authCaller, ok := authenticatedHashes[batchHash] + if !ok { + logger.Warn("batch not authenticated", + "txHash", tx.Hash(), "batchHash", batchHash) + return false + } + // The batch tx must be submitted by the same address that authenticated it. + sender, err := dsCfg.l1Signer.Sender(tx) + if err != nil { + logger.Warn("authenticated batch tx with invalid signature", + "txHash", tx.Hash(), "batchHash", batchHash, "err", err) + return false + } + if sender != authCaller { + logger.Warn("authenticated batch submitted by a different sender than the authenticating caller", + "txHash", tx.Hash(), "batchHash", batchHash, "sender", sender, "authCaller", authCaller) return false } return true diff --git a/op-node/rollup/derive/espresso_batch_authenticator_test.go b/op-node/rollup/derive/espresso_batch_authenticator_test.go new file mode 100644 index 00000000000..f9450531574 --- /dev/null +++ b/op-node/rollup/derive/espresso_batch_authenticator_test.go @@ -0,0 +1,380 @@ +package derive + +import ( + "context" + "errors" + "math/rand" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" +) + +// batchAuthLog builds a BatchInfoAuthenticated log as emitted by the +// BatchAuthenticator contract at address authenticatorAddr: Topics[0] is the +// event signature hash, Topics[1] is the indexed caller (the address that +// emitted the event), and the commitment is the first 32 bytes of the data. +func batchAuthLog(authenticatorAddr, caller common.Address, commitment common.Hash) *types.Log { + return &types.Log{ + Address: authenticatorAddr, + Topics: []common.Hash{ + BatchInfoAuthenticatedABIHash, + common.BytesToHash(caller.Bytes()), + }, + Data: commitment.Bytes(), + } +} + +func TestComputeCalldataBatchHash(t *testing.T) { + data := []byte("hello world") + hash := ComputeCalldataBatchHash(data) + expected := crypto.Keccak256Hash(data) + require.Equal(t, expected, hash) +} + +func TestComputeCalldataBatchHashEmpty(t *testing.T) { + hash := ComputeCalldataBatchHash([]byte{}) + expected := crypto.Keccak256Hash([]byte{}) + require.Equal(t, expected, hash) +} + +func TestComputeBlobBatchHash(t *testing.T) { + h1 := common.HexToHash("0x0100000000000000000000000000000000000000000000000000000000000001") + h2 := common.HexToHash("0x0100000000000000000000000000000000000000000000000000000000000002") + + hash := ComputeBlobBatchHash([]common.Hash{h1, h2}) + + // Manually compute expected: keccak256(h1 ++ h2) + concatenated := make([]byte, 64) + copy(concatenated[0:32], h1[:]) + copy(concatenated[32:64], h2[:]) + expected := crypto.Keccak256Hash(concatenated) + require.Equal(t, expected, hash) +} + +func TestComputeBlobBatchHashSingle(t *testing.T) { + h := common.HexToHash("0xabcdef") + hash := ComputeBlobBatchHash([]common.Hash{h}) + expected := crypto.Keccak256Hash(h[:]) + require.Equal(t, expected, hash) +} + +// buildL1Chain creates a chain of L1BlockRef values with proper parent-hash linkage. +// The chain goes from block number `start` to `end` (inclusive). +// Returns a slice indexed by block number (relative to start), and the full map by number. +func buildL1Chain(rng *rand.Rand, start, end uint64) map[uint64]eth.L1BlockRef { + chain := make(map[uint64]eth.L1BlockRef) + for num := start; num <= end; num++ { + ref := eth.L1BlockRef{ + Number: num, + Hash: testutils.RandomHash(rng), + } + if num > start { + ref.ParentHash = chain[num-1].Hash + } + chain[num] = ref + } + return chain +} + +func TestCollectAuthenticatedBatches(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + ctx := context.Background() + rng := rand.New(rand.NewSource(1234)) + caches := NewBatchAuthCaches() + + authenticatorAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + caller := common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + batchHash := crypto.Keccak256Hash([]byte("test batch data")) + + // Build a matching receipt + matchingReceipts := types.Receipts{ + { + Status: types.ReceiptStatusSuccessful, + Logs: []*types.Log{batchAuthLog(authenticatorAddr, caller, batchHash)}, + }, + } + emptyReceipts := types.Receipts{} + + // expectChainTraversal sets up mock expectations for a backward parent-hash + // traversal from chain[end] down to chain[start]. For each block it expects + // FetchReceipts (by hash), and for all blocks except the first (end) it + // expects L1BlockRefByHash to resolve the parent hash. + // receiptsByBlock allows overriding receipts for specific block numbers. + expectChainTraversal := func(l1F *testutils.MockL1Source, chain map[uint64]eth.L1BlockRef, start, end uint64, receiptsByBlock map[uint64]types.Receipts) { + for num := end; num >= start; num-- { + ref := chain[num] + receipts := emptyReceipts + if r, ok := receiptsByBlock[num]; ok { + receipts = r + } + l1F.ExpectFetchReceipts(ref.Hash, nil, receipts, nil) + // L1BlockRefByHash is called for every block except the first one (ref itself) + if num > start { + l1F.ExpectL1BlockRefByHash(chain[num-1].Hash, chain[num-1], nil) + } + if num == 0 { + break // avoid underflow + } + } + } + + t.Run("found in same block", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // Auth event is in block 200 (same block as ref). Traversal goes 200 -> 100. + expectChainTraversal(l1F, chain, 100, 200, map[uint64]types.Receipts{ + 200: matchingReceipts, + }) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Equal(t, caller, result[batchHash]) + require.Len(t, result, 1) + l1F.AssertExpectations(t) + }) + + t.Run("found in earliest block of window", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // Auth event is in block 100 (last block of the lookback window). + expectChainTraversal(l1F, chain, 100, 200, map[uint64]types.Receipts{ + 100: matchingReceipts, + }) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Equal(t, caller, result[batchHash]) + require.Len(t, result, 1) + l1F.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // No auth event in any block in the window + expectChainTraversal(l1F, chain, 100, 200, nil) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Len(t, result, 0) + l1F.AssertExpectations(t) + }) + + t.Run("low block number - window clamps to 0", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 0, 10) + ref := chain[10] + + // Window should clamp to [0, 10]. Auth event is in block 10. + expectChainTraversal(l1F, chain, 0, 10, map[uint64]types.Receipts{ + 10: matchingReceipts, + }) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Equal(t, caller, result[batchHash]) + require.Len(t, result, 1) + l1F.AssertExpectations(t) + }) + + t.Run("multiple hashes collected", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 0, 10) + ref := chain[10] + + batchHash2 := crypto.Keccak256Hash([]byte("second batch")) + caller2 := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + multiReceipts := types.Receipts{ + { + Status: types.ReceiptStatusSuccessful, + Logs: []*types.Log{ + batchAuthLog(authenticatorAddr, caller, batchHash), + batchAuthLog(authenticatorAddr, caller2, batchHash2), + }, + }, + } + + // Both auth events are in block 10 + expectChainTraversal(l1F, chain, 0, 10, map[uint64]types.Receipts{ + 10: multiReceipts, + }) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, caller, result[batchHash]) + require.Equal(t, caller2, result[batchHash2]) + l1F.AssertExpectations(t) + }) + + t.Run("newest caller wins when commitment authenticated twice", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + caller2 := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + // Older block 100 authenticates batchHash with caller2; newer block 200 + // authenticates the same batchHash with caller. The newest (block 200) + // caller must win. + olderReceipts := types.Receipts{ + { + Status: types.ReceiptStatusSuccessful, + Logs: []*types.Log{batchAuthLog(authenticatorAddr, caller2, batchHash)}, + }, + } + expectChainTraversal(l1F, chain, 100, 200, map[uint64]types.Receipts{ + 200: matchingReceipts, // caller + 100: olderReceipts, // caller2 + }) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, caller, result[batchHash]) + l1F.AssertExpectations(t) + }) +} + +// TestCollectAuthenticatedBatchesBlockRefCache verifies that the block ref LRU cache +// eliminates redundant L1BlockRefByHash RPC calls when processing consecutive L1 blocks. +// On the first call (block N), all ~100 L1BlockRefByHash calls are made. On the second +// call (block N+1), the overlapping window means ~99 block refs are already cached, +// so only 1 new L1BlockRefByHash call is needed. +func TestCollectAuthenticatedBatchesBlockRefCache(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + ctx := context.Background() + rng := rand.New(rand.NewSource(5678)) + caches := NewBatchAuthCaches() + + authenticatorAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + emptyReceipts := types.Receipts{} + + // Build a chain long enough for two consecutive lookback windows: + // Block 200's window is [100, 200], block 201's window is [101, 201]. + chain := buildL1Chain(rng, 100, 201) + + // --- First call: block 200, window [100, 200] --- + // Expects all 101 FetchReceipts calls and 100 L1BlockRefByHash calls (full traversal). + l1F := &testutils.MockL1Source{} + for num := uint64(200); num >= 100; num-- { + ref := chain[num] + l1F.ExpectFetchReceipts(ref.Hash, nil, emptyReceipts, nil) + if num > 100 { + l1F.ExpectL1BlockRefByHash(chain[num-1].Hash, chain[num-1], nil) + } + } + + result, err := CollectAuthenticatedBatches(ctx, l1F, chain[200], authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Len(t, result, 0) + l1F.AssertExpectations(t) + + // --- Second call: block 201, window [101, 201] --- + // Both receipt and block ref caches are warm for blocks [100, 200]. + // Only block 201 needs FetchReceipts (new block, not in receipt cache). + // Only block 200 needs L1BlockRefByHash resolution — but it was cached as the + // `ref` of the previous call (we cache ref.Hash -> ref at the top of the function). + // So NO L1BlockRefByHash calls should be needed at all. + l1F2 := &testutils.MockL1Source{} + // Only block 201's receipts are uncached + l1F2.ExpectFetchReceipts(chain[201].Hash, nil, emptyReceipts, nil) + // All block refs in [101, 200] are cached from the first call, and block 200 + // was cached as the ref argument. No L1BlockRefByHash calls expected. + + result2, err := CollectAuthenticatedBatches(ctx, l1F2, chain[201], authenticatorAddr, caches, logger) + require.NoError(t, err) + require.Len(t, result2, 0) + l1F2.AssertExpectations(t) +} + +// TestCollectAuthenticatedBatchesResetOnNotFound verifies that when an L1 block +// in the lookback window is no longer available (FetchReceipts or L1BlockRefByHash +// returns ethereum.NotFound, e.g. after a reorg orphaned it), CollectAuthenticatedBatches +// returns a ResetError rather than a TemporaryError. A TemporaryError would cause the +// pipeline to retry the same step forever (a silent stall); a ResetError makes it +// re-derive from a canonical origin, matching how the calldata/blob data sources +// classify a missing ref block. +func TestCollectAuthenticatedBatchesResetOnNotFound(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + ctx := context.Background() + rng := rand.New(rand.NewSource(9012)) + + authenticatorAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + emptyReceipts := types.Receipts{} + + t.Run("FetchReceipts NotFound on an ancestor", func(t *testing.T) { + caches := NewBatchAuthCaches() + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // Block 200 (ref) is fetched fine and its parent ref resolves, then the + // ancestor block 199's receipts come back NotFound. + l1F.ExpectFetchReceipts(chain[200].Hash, nil, emptyReceipts, nil) + l1F.ExpectL1BlockRefByHash(chain[199].Hash, chain[199], nil) + l1F.ExpectFetchReceipts(chain[199].Hash, nil, types.Receipts(nil), ethereum.NotFound) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.Nil(t, result) + require.ErrorIs(t, err, ErrReset) + require.ErrorIs(t, err, ethereum.NotFound) + l1F.AssertExpectations(t) + }) + + t.Run("L1BlockRefByHash NotFound on an ancestor", func(t *testing.T) { + caches := NewBatchAuthCaches() + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // Block 200 (ref) is fetched fine, but resolving its parent ref (block 199) + // comes back NotFound. + l1F.ExpectFetchReceipts(chain[200].Hash, nil, emptyReceipts, nil) + l1F.ExpectL1BlockRefByHash(chain[199].Hash, eth.L1BlockRef{}, ethereum.NotFound) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.Nil(t, result) + require.ErrorIs(t, err, ErrReset) + require.ErrorIs(t, err, ethereum.NotFound) + l1F.AssertExpectations(t) + }) + + t.Run("non-NotFound error stays temporary", func(t *testing.T) { + caches := NewBatchAuthCaches() + l1F := &testutils.MockL1Source{} + chain := buildL1Chain(rng, 100, 200) + ref := chain[200] + + // A transient RPC failure (not NotFound) must remain a TemporaryError so the + // pipeline retries rather than resetting. + l1F.ExpectFetchReceipts(chain[200].Hash, nil, types.Receipts(nil), errors.New("connection refused")) + + result, err := CollectAuthenticatedBatches(ctx, l1F, ref, authenticatorAddr, caches, logger) + require.Nil(t, result) + require.ErrorIs(t, err, ErrTemporary) + require.NotErrorIs(t, err, ErrReset) + l1F.AssertExpectations(t) + }) +} + +func TestBatchInfoAuthenticatedABIHash(t *testing.T) { + // Verify the ABI hash matches what Solidity would compute for + // BatchInfoAuthenticated(bytes32 commitment, address indexed caller). + expected := crypto.Keccak256Hash([]byte("BatchInfoAuthenticated(bytes32,address)")) + require.Equal(t, expected, BatchInfoAuthenticatedABIHash) +} diff --git a/op-node/rollup/derive/espresso_blob_data_source_test.go b/op-node/rollup/derive/espresso_blob_data_source_test.go new file mode 100644 index 00000000000..fb96ed62005 --- /dev/null +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -0,0 +1,318 @@ +package derive + +import ( + "context" + "crypto/ecdsa" + "math/big" + "math/rand" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum/go-ethereum/log" +) + +// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication for both +// calldata and blob transactions in the blob data source path. +// +// Event-based authentication is only active post-Espresso; the fixture +// activates the fork at L1 origin time 0 (genesis) so all test refs satisfy +// ref.Time >= *EspressoTime. +func TestDataAndHashesFromTxsEventAuth(t *testing.T) { + rng := rand.New(rand.NewSource(9999)) + privateKey := testutils.InsecureRandomKey(rng) + altKey := testutils.InsecureRandomKey(rng) + batcherAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)) + altAddr := crypto.PubkeyToAddress(*altKey.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) + espressoTime := uint64(0) + config := DataSourceConfig{ + l1Signer: signer, + batchInboxAddress: batchInboxAddr, + rollupCfg: &rollup.Config{ + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + }, + batchAuthCaches: NewBatchAuthCaches(), + } + + ctx := context.Background() + + t.Run("authenticated calldata tx accepted", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := &types.LegacyTx{ + Nonce: rng.Uint64(), + GasPrice: new(big.Int).SetUint64(rng.Uint64()), + Gas: 2_000_000, + To: &batchInboxAddr, + Value: big.NewInt(10), + Data: testutils.RandomData(rng, 200), + } + calldataTx, _ := types.SignNewTx(privateKey, signer, txData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(calldataTx.Data()) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 1, len(data)) + require.Equal(t, 0, len(blobHashes)) + require.NotNil(t, data[0].calldata) + require.Equal(t, eth.Data(calldataTx.Data()), *data[0].calldata) + l1F.AssertExpectations(t) + }) + + t.Run("authenticated blob tx accepted", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + blobHash := testutils.RandomHash(rng) + blobTxData := &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batchInboxAddr, + Data: testutils.RandomData(rng, 100), + BlobHashes: []common.Hash{blobHash}, + } + blobTx, _ := types.SignNewTx(privateKey, signer, blobTxData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeBlobBatchHash([]common.Hash{blobHash}) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + 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 + l1F.AssertExpectations(t) + }) + + t.Run("unknown sender rejected without auth event", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := &types.LegacyTx{ + Nonce: rng.Uint64(), + GasPrice: new(big.Int).SetUint64(rng.Uint64()), + Gas: 2_000_000, + To: &batchInboxAddr, + Value: big.NewInt(10), + Data: testutils.RandomData(rng, 200), + } + // Signed by an unknown key (not batcherAddr), no auth event — should be rejected + calldataTx, _ := types.SignNewTx(altKey, signer, txData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) // no auth events + + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 0, len(data), "unknown sender tx without auth event should be rejected") + require.Equal(t, 0, len(blobHashes)) + l1F.AssertExpectations(t) + }) + + t.Run("fallback batcher without auth event rejected", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := &types.LegacyTx{ + Nonce: rng.Uint64(), + GasPrice: new(big.Int).SetUint64(rng.Uint64()), + Gas: 2_000_000, + To: &batchInboxAddr, + Value: big.NewInt(10), + Data: testutils.RandomData(rng, 200), + } + // Signed by batcher key (SystemConfig batcherAddr), no auth event — should be rejected + // because all batchers now require event-based authentication + calldataTx, _ := types.SignNewTx(privateKey, signer, txData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) // no auth events + + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 0, len(data), "fallback batcher without auth event should be rejected") + require.Equal(t, 0, len(blobHashes)) + l1F.AssertExpectations(t) + }) + + t.Run("non-batcher sender accepted when it matches the auth caller", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := &types.LegacyTx{ + Nonce: rng.Uint64(), + GasPrice: new(big.Int).SetUint64(rng.Uint64()), + Gas: 2_000_000, + To: &batchInboxAddr, + Value: big.NewInt(10), + Data: testutils.RandomData(rng, 200), + } + // Signed by alt key (not the SystemConfig batcher), and the auth event was + // emitted by that same alt address — should be accepted. + calldataTx, _ := types.SignNewTx(altKey, signer, txData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(calldataTx.Data()) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, altAddr, []common.Hash{batchHash}) + + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 1, len(data)) + require.Equal(t, 0, len(blobHashes)) + require.NotNil(t, data[0].calldata) + require.Equal(t, eth.Data(calldataTx.Data()), *data[0].calldata) // the authenticated tx, not just any + l1F.AssertExpectations(t) + }) + + t.Run("authenticated tx rejected when sender differs from auth caller", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := &types.LegacyTx{ + Nonce: rng.Uint64(), + GasPrice: new(big.Int).SetUint64(rng.Uint64()), + Gas: 2_000_000, + To: &batchInboxAddr, + Value: big.NewInt(10), + Data: testutils.RandomData(rng, 200), + } + // Signed by alt key, but the commitment was authenticated by batcherAddr. + // The submitter must match the auth caller — should be rejected. + calldataTx, _ := types.SignNewTx(altKey, signer, txData) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(calldataTx.Data()) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 0, len(data), "batch authenticated by a different address than the submitter must be rejected") + require.Equal(t, 0, len(blobHashes)) + l1F.AssertExpectations(t) + }) +} + +// TestDataAndHashesFromTxsForkBoundary exercises the Espresso fork gate flipping in the +// blob data source path (dataAndHashesFromTxs) across a single fixed DataSourceConfig. +// +// This is the path a chain with Ecotone active actually runs: OpenData always selects the +// blob source, and calldata (type-2) batches flow through its non-blob branch. Pre-Espresso +// (L1 origin time < EspressoTime) must use upstream sender-based authorization with no event +// scanning; at and after activation it must switch to event-based authentication. The gate is +// implemented separately here from the calldata source, so this mirrors +// TestDataFromEVMTransactionsForkBoundary to pin both copies. +func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { + rng := rand.New(rand.NewSource(7777)) + privateKey := testutils.InsecureRandomKey(rng) + altKey := 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) + + // Fork activates at L1 origin time 1000. A single config is reused across all + // sub-tests; only ref.Time changes to cross the boundary. + espressoTime := uint64(1000) + config := DataSourceConfig{ + l1Signer: signer, + batchInboxAddress: batchInboxAddr, + rollupCfg: &rollup.Config{ + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + }, + batchAuthCaches: NewBatchAuthCaches(), + } + + ctx := context.Background() + + // newCalldataBatchTx builds a type-2 calldata batch tx to the inbox (the tx shape an + // Ecotone-active, calldata-batching chain submits through the blob source). + newCalldataBatchTx := func(t *testing.T, author *ecdsa.PrivateKey, data []byte) *types.Transaction { + t.Helper() + tx, err := types.SignNewTx(author, signer, &types.DynamicFeeTx{ + ChainID: chainId, Nonce: rng.Uint64(), Gas: 2_000_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: data, + }) + require.NoError(t, err) + return tx + } + + t.Run("pre-fork: batcher accepted via sender auth, no event scan", func(t *testing.T) { + // The empty mock asserts pre-fork derivation performs zero L1 receipt scanning: + // any FetchReceipts/L1BlockRefByHash call would be an unexpected call and panic. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 200) + tx := newCalldataBatchTx(t, privateKey, txData) + + 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 batcher tx should be accepted via sender-based auth") + require.Equal(t, 0, len(hashes)) + require.NotNil(t, data[0].calldata) + require.Equal(t, eth.Data(txData), *data[0].calldata) + l1F.AssertExpectations(t) + }) + + t.Run("pre-fork: non-batcher sender rejected", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + tx := newCalldataBatchTx(t, altKey, testutils.RandomData(rng, 200)) + + 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, 0, len(data), "pre-fork tx from a non-batcher sender should be rejected") + require.Equal(t, 0, len(hashes)) + l1F.AssertExpectations(t) + }) + + t.Run("activation block: same batcher tx rejected without auth event", func(t *testing.T) { + // At the exact activation time (ref.Time == EspressoTime) the event-based path is + // active, so a sender-only batcher tx is no longer sufficient. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 200) + tx := newCalldataBatchTx(t, privateKey, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + + data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 0, len(data), "post-fork batcher tx without an auth event must be rejected") + require.Equal(t, 0, len(hashes)) + l1F.AssertExpectations(t) + }) + + t.Run("activation block: same batcher tx accepted with auth event", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 200) + tx := newCalldataBatchTx(t, privateKey, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(tx.Data()) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 1, len(data), "post-fork batcher tx with a matching auth event must be accepted") + require.Equal(t, 0, len(hashes)) + require.NotNil(t, data[0].calldata) + require.Equal(t, eth.Data(txData), *data[0].calldata) + l1F.AssertExpectations(t) + }) +} diff --git a/op-node/rollup/derive/espresso_calldata_source_test.go b/op-node/rollup/derive/espresso_calldata_source_test.go new file mode 100644 index 00000000000..b1d314d3aae --- /dev/null +++ b/op-node/rollup/derive/espresso_calldata_source_test.go @@ -0,0 +1,447 @@ +package derive + +import ( + "context" + "crypto/ecdsa" + "math/big" + "math/rand" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" +) + +// mockAuthEvents sets up L1 mock expectations for CollectAuthenticatedBatches to find auth events +// for the given batch hashes at the given ref's block number. Auth events for batch hashes in +// `authenticated` are placed in the ref block's receipts; all other blocks in the lookback +// window have empty receipts. +// +// CollectAuthenticatedBatches traverses backward from ref via parent hashes, so this helper +// builds a chain of L1BlockRef values with proper parent-hash linkage, sets up FetchReceipts +// for each block, and L1BlockRefByHash for each parent. +// +// The auth events are emitted with `caller` as the indexed caller, which the +// pipeline matches against the batch transaction's L1 sender. Tests pass the +// expected batcher address here. +// +// Returns the updated ref with its ParentHash properly set to the chain. Callers must use +// the returned ref when calling functions that invoke CollectAuthenticatedBatches. +func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1BlockRef, authenticatorAddr, caller common.Address, authenticated []common.Hash) eth.L1BlockRef { + startBlock := ref.Number + if startBlock > BatchAuthLookbackWindow { + startBlock = ref.Number - BatchAuthLookbackWindow + } else { + startBlock = 0 + } + windowSize := ref.Number - startBlock + 1 + + // Build the auth receipts for the ref block. The commitment is the unindexed + // data argument; only the caller is indexed (Topics[1]). + var authLogs []*types.Log + for _, bh := range authenticated { + authLogs = append(authLogs, &types.Log{ + Address: authenticatorAddr, + Topics: []common.Hash{ + BatchInfoAuthenticatedABIHash, + common.BytesToHash(caller.Bytes()), + }, + Data: bh.Bytes(), + }) + } + authReceipts := types.Receipts{} + if len(authLogs) > 0 { + authReceipts = types.Receipts{{Status: types.ReceiptStatusSuccessful, Logs: authLogs}} + } + + // Build parent-hash-linked chain from startBlock to ref.Number. + // chain[i] corresponds to block number startBlock + i. + chain := make([]eth.L1BlockRef, windowSize) + for i := uint64(0); i < windowSize; i++ { + blockNum := startBlock + i + if blockNum == ref.Number { + chain[i] = ref + } else { + chain[i] = eth.L1BlockRef{Number: blockNum, Hash: testutils.RandomHash(rng)} + } + if i > 0 { + chain[i].ParentHash = chain[i-1].Hash + } + } + + // Update the ref at the end of the chain with the correct ParentHash + updatedRef := chain[windowSize-1] + + // Set up expectations for backward traversal: ref -> ref-1 -> ... -> startBlock + for i := int(windowSize) - 1; i >= 0; i-- { + blockRef := chain[i] + if blockRef.Number == ref.Number { + l1F.ExpectFetchReceipts(blockRef.Hash, nil, authReceipts, nil) + } else { + l1F.ExpectFetchReceipts(blockRef.Hash, nil, types.Receipts{}, nil) + } + // L1BlockRefByHash is called for every parent except when we've reached the end of the window + if i > 0 { + l1F.ExpectL1BlockRefByHash(chain[i-1].Hash, chain[i-1], nil) + } + } + + return updatedRef +} + +// TestDataFromEVMTransactionsEventAuth tests event-based batch authentication +// where a BatchInfoAuthenticated event in the lookback window authorizes a batch. +// +// Event-based authentication is only active post-Espresso; the fixture +// activates the fork at L1 origin time 0 (genesis) so all test refs satisfy +// ref.Time >= *EspressoTime. +func TestDataFromEVMTransactionsEventAuth(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + batcherPriv := testutils.RandomKey() + altAuthor := testutils.RandomKey() + batchInboxAddr := testutils.RandomAddress(rng) + authenticatorAddr := testutils.RandomAddress(rng) + batcherAddr := crypto.PubkeyToAddress(batcherPriv.PublicKey) + altAuthorAddr := crypto.PubkeyToAddress(altAuthor.PublicKey) + signer := types.NewCancunSigner(big.NewInt(100)) + + espressoTime := uint64(0) + dsCfg := DataSourceConfig{ + l1Signer: signer, + batchInboxAddress: batchInboxAddr, + rollupCfg: &rollup.Config{ + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + }, + batchAuthCaches: NewBatchAuthCaches(), + } + + ctx := context.Background() + logger := testlog.Logger(t, log.LevelDebug) + + t.Run("authenticated tx accepted", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData, + }) + require.NoError(t, err) + + // Use block number 1 so lookback window is [0, 1] — only 2 blocks to mock + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(txData) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, eth.Data(txData), out[0]) + l1F.AssertExpectations(t) + }) + + t.Run("unauthenticated tx from unknown sender rejected", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(altAuthor, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + // No auth events — empty authenticated list + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0) + l1F.AssertExpectations(t) + }) + + t.Run("fallback batcher without auth event rejected", func(t *testing.T) { + // The fallback batcher now also authenticates via BatchAuthenticator events. + // Without an auth event, even the SystemConfig batcher address is rejected. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0, "fallback batcher without auth event should be rejected") + l1F.AssertExpectations(t) + }) + + t.Run("wrong inbox address rejected without auth check", func(t *testing.T) { + // Tx to wrong address should be filtered by isValidBatchTx. + // CollectAuthenticatedBatches still runs (it's a block-level operation), + // but no tx passes the inbox address check. + l1F := &testutils.MockL1Source{} + wrongAddr := testutils.RandomAddress(rng) + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &wrongAddr, Data: txData, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + // Mock the lookback window scan (returns no authenticated hashes) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0) + l1F.AssertExpectations(t) + }) + + t.Run("mixed: only event-authenticated txs accepted", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + // tx1: has auth event — should be accepted + txData1 := testutils.RandomData(rng, 100) + tx1, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData1, + }) + require.NoError(t, err) + + // tx2: no auth event — should be rejected even though sender is batcherAddr + txData2 := testutils.RandomData(rng, 100) + tx2, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 1, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData2, + }) + require.NoError(t, err) + + // tx3: unknown sender without auth event — should be rejected + txData3 := testutils.RandomData(rng, 100) + tx3, err := types.SignNewTx(altAuthor, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 2, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData3, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash1 := ComputeCalldataBatchHash(txData1) + // Only tx1 has an auth event (caller = batcherAddr, matching tx1's sender). + // tx2 and tx3 do not — both should be rejected. + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash1}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx1, tx2, tx3}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 1, "only event-authenticated tx should pass") + require.Equal(t, eth.Data(txData1), out[0]) + l1F.AssertExpectations(t) + }) + + t.Run("auth event accepts a non-batcher sender that matches its caller", func(t *testing.T) { + // Event-based mode does not require the SystemConfig batcher: any sender is + // accepted as long as it matches the caller that emitted the auth event. + // Here altAuthor both submits the batch and is the auth event caller. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(altAuthor, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(txData) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, altAuthorAddr, []common.Hash{batchHash}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, eth.Data(txData), out[0]) + l1F.AssertExpectations(t) + }) + + t.Run("authenticated batch from a different sender than the caller is rejected", func(t *testing.T) { + // The batch commitment is authenticated, but by batcherAddr; the batch tx is + // submitted by altAuthor. The sender must match the auth event caller, so the + // batch is rejected even though the commitment was authenticated. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx, err := types.SignNewTx(altAuthor, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txData, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(txData) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0, "batch authenticated by a different address than the submitter must be rejected") + l1F.AssertExpectations(t) + }) + + t.Run("multiple authenticated txs each accepted for their own commitment", func(t *testing.T) { + // Two distinct batches, each authenticated by its own commitment event from the + // batcher. Both must be accepted, in order, each mapped to its own data — verifying + // every tx is matched against its own commitment, not just "some" authenticated entry. + l1F := &testutils.MockL1Source{} + txDataA := testutils.RandomData(rng, 100) + txA, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txDataA, + }) + require.NoError(t, err) + txDataB := testutils.RandomData(rng, 100) + txB, err := types.SignNewTx(batcherPriv, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 1, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: txDataB, + }) + require.NoError(t, err) + + ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, + []common.Hash{ComputeCalldataBatchHash(txDataA), ComputeCalldataBatchHash(txDataB)}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{txA, txB}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 2) + require.Equal(t, eth.Data(txDataA), out[0], "first tx must map to its own data") + require.Equal(t, eth.Data(txDataB), out[1], "second tx must map to its own data") + l1F.AssertExpectations(t) + }) +} + +// TestDataFromEVMTransactionsForkBoundary exercises the Espresso fork gate flipping +// across a single fixed DataSourceConfig. Pre-Espresso (L1 origin time < EspressoTime) +// must use upstream sender-based authorization with no event scanning at all; at and +// after activation (L1 origin time >= EspressoTime) it must switch to event-based +// authentication. +// +// This pins the gate boundary — the IsEspresso(ref.Time) check (`timestamp >= +// *EspressoTime`) consulted in DataFromEVMTransactions and isBatchTxAuthorized. The same +// batcher transaction is accepted pre-fork without any auth event, but rejected at the +// activation block unless a BatchInfoAuthenticated event authorizes it. A regression in +// the boundary is caught in both directions: a pre-fork block would start scanning +// receipts (unexpected mock calls panic), and the activation block would otherwise accept +// an unauthenticated batch. +func TestDataFromEVMTransactionsForkBoundary(t *testing.T) { + rng := rand.New(rand.NewSource(99)) + batcherPriv := testutils.RandomKey() + altAuthor := testutils.RandomKey() + batchInboxAddr := testutils.RandomAddress(rng) + authenticatorAddr := testutils.RandomAddress(rng) + batcherAddr := crypto.PubkeyToAddress(batcherPriv.PublicKey) + signer := types.NewCancunSigner(big.NewInt(100)) + + // Fork activates at L1 origin time 1000. A single config is reused across all + // sub-tests; only ref.Time changes to cross the boundary. + espressoTime := uint64(1000) + dsCfg := DataSourceConfig{ + l1Signer: signer, + batchInboxAddress: batchInboxAddr, + rollupCfg: &rollup.Config{ + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + }, + batchAuthCaches: NewBatchAuthCaches(), + } + + ctx := context.Background() + logger := testlog.Logger(t, log.LevelDebug) + + newBatchTx := func(t *testing.T, author *ecdsa.PrivateKey, data []byte) *types.Transaction { + t.Helper() + tx, err := types.SignNewTx(author, signer, &types.DynamicFeeTx{ + ChainID: big.NewInt(100), Nonce: 0, Gas: 100_000, + GasTipCap: big.NewInt(2 * params.GWei), GasFeeCap: big.NewInt(30 * params.GWei), + To: &batchInboxAddr, Data: data, + }) + require.NoError(t, err) + return tx + } + + t.Run("pre-fork: batcher accepted via sender auth, no event scan", func(t *testing.T) { + // The empty mock asserts pre-fork derivation performs zero L1 receipt scanning: + // any FetchReceipts/L1BlockRefByHash call would be an unexpected call and panic. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx := newBatchTx(t, batcherPriv, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime - 1, Hash: testutils.RandomHash(rng)} + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 1, "pre-fork batcher tx should be accepted via sender-based auth") + require.Equal(t, eth.Data(txData), out[0]) + l1F.AssertExpectations(t) + }) + + t.Run("pre-fork: non-batcher sender rejected", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx := newBatchTx(t, altAuthor, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime - 1, Hash: testutils.RandomHash(rng)} + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0, "pre-fork tx from a non-batcher sender should be rejected") + l1F.AssertExpectations(t) + }) + + t.Run("activation block: same batcher tx rejected without auth event", func(t *testing.T) { + // At the exact activation time (ref.Time == EspressoTime) the event-based path is + // active, so a sender-only batcher tx is no longer sufficient. + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx := newBatchTx(t, batcherPriv, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 0, "post-fork batcher tx without an auth event must be rejected") + l1F.AssertExpectations(t) + }) + + t.Run("activation block: same batcher tx accepted with auth event", func(t *testing.T) { + l1F := &testutils.MockL1Source{} + txData := testutils.RandomData(rng, 100) + tx := newBatchTx(t, batcherPriv, txData) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime, Hash: testutils.RandomHash(rng)} + batchHash := ComputeCalldataBatchHash(txData) + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{batchHash}) + + out, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, types.Transactions{tx}, l1F, ref, logger) + require.NoError(t, err) + require.Len(t, out, 1, "post-fork batcher tx with a matching auth event must be accepted") + require.Equal(t, eth.Data(txData), out[0]) + l1F.AssertExpectations(t) + }) +} diff --git a/op-node/rollup/derive/params.go b/op-node/rollup/derive/params.go index c4511d95088..010e84fe46f 100644 --- a/op-node/rollup/derive/params.go +++ b/op-node/rollup/derive/params.go @@ -21,6 +21,16 @@ func frameSize(frame Frame) uint64 { // or transaction per block allowed in a span batch. const MaxSpanBatchElementCount = 10_000_000 +// BatchAuthLookbackWindow is the number of L1 blocks before a batch submission to +// scan for a BatchInfoAuthenticated event. The authentication transaction must land +// in this window (or in the same block as the batch submission) for the batch to be +// considered valid post-Espresso. +// +// At ~12s per L1 block, 100 blocks ≈ 20 minutes. This gives the batcher time to land +// the batch data transaction on L1 after the authentication transaction, even under +// L1 congestion or batcher restarts. +const BatchAuthLookbackWindow uint64 = 100 + // DuplicateErr is returned when a newly read frame is already known var DuplicateErr = errors.New("duplicate frame") diff --git a/op-node/rollup/espresso_types.go b/op-node/rollup/espresso_types.go new file mode 100644 index 00000000000..45ecc8b5b98 --- /dev/null +++ b/op-node/rollup/espresso_types.go @@ -0,0 +1,13 @@ +package rollup + +// IsEspresso returns true if the Espresso upgrade is active at or past the +// given timestamp. EspressoTime is conceptually an L2-timestamp fork activation +// time, but the derivation pipeline calls this with the L1 origin time of the +// enclosing L1 block (mirroring upstream's ecotoneTime treatment), so the fork +// is effectively gated per L2 epoch. When active, the derivation pipeline runs +// all Espresso-specific semantics (event-based batch authentication via the +// BatchAuthenticator contract). When inactive, the pipeline behaves exactly as +// upstream Optimism. +func (c *Config) IsEspresso(timestamp uint64) bool { + return c != nil && c.EspressoTime != nil && timestamp >= *c.EspressoTime +} diff --git a/op-node/rollup/types.go b/op-node/rollup/types.go index 23b2e1cdb38..a128bb52c47 100644 --- a/op-node/rollup/types.go +++ b/op-node/rollup/types.go @@ -38,6 +38,8 @@ var ( ErrChainIDsSame = errors.New("L1 and L2 chain IDs must be different") ErrL1ChainIDNotPositive = errors.New("L1 chain ID must be non-zero and positive") ErrL2ChainIDNotPositive = errors.New("L2 chain ID must be non-zero and positive") + + ErrMissingBatchAuthenticatorAddress = errors.New("missing batch authenticator address when Espresso is enabled") ) type Genesis struct { @@ -166,6 +168,22 @@ type Config struct { // This feature (de)activates by L1 origin timestamp, to keep a consistent L1 block info per L2 // epoch. PectraBlobScheduleTime *uint64 `json:"pectra_blob_schedule_time,omitempty"` + + // EspressoTime sets the activation time of the Espresso upgrade. + // Pre-fork, the derivation pipeline behaves exactly as upstream Optimism: batches are + // accepted based on the L1 transaction sender matching the SystemConfig batcher address. + // Post-fork, batches must be authenticated via BatchInfoAuthenticated events emitted by + // the BatchAuthenticator contract; sender-based authorization is rejected. + // EspressoTime is conceptually an L2-timestamp fork activation time, but the + // derivation pipeline gates on it by comparing against the L1 origin time of the + // enclosing L1 block (the L2 epoch's L1 origin), mirroring upstream's ecotoneTime + // treatment, to keep a consistent batch-authorization decision per L2 epoch. + // Active if EspressoTime != nil && the block's L1 origin time >= *EspressoTime. + EspressoTime *uint64 `json:"espresso_time,omitempty"` + + // BatchAuthenticatorAddress is the L1 address of the BatchAuthenticator contract whose + // BatchInfoAuthenticated(bytes32,address) events the derivation pipeline scans post-Espresso. + BatchAuthenticatorAddress common.Address `json:"batch_authenticator_address,omitempty,omitzero"` } // ValidateL1Config checks L1 config variables for errors. @@ -367,6 +385,12 @@ func (cfg *Config) Check() error { return err } + // When Espresso is enabled, batches must be authenticated via BatchInfoAuthenticated events + // emitted by the BatchAuthenticator contract, so a non-zero authenticator address is required. + if cfg.EspressoTime != nil && cfg.BatchAuthenticatorAddress == (common.Address{}) { + return ErrMissingBatchAuthenticatorAddress + } + return nil } @@ -869,6 +893,10 @@ func (c *Config) forEachFork(callback func(name string, logName string, time *ui callback("Jovian", "jovian_time", c.JovianTime) callback("Karst", "karst_time", c.KarstTime) callback("Interop", "interop_time", c.InteropTime) + if c.EspressoTime != nil { + // only report if config is set + callback("Espresso", "espresso_time", c.EspressoTime) + } } func (c *Config) ParseRollupConfig(in io.Reader) error { diff --git a/op-node/rollup/types_test.go b/op-node/rollup/types_test.go index be5262eddfc..207b0215d2d 100644 --- a/op-node/rollup/types_test.go +++ b/op-node/rollup/types_test.go @@ -572,6 +572,24 @@ func TestConfig_Check(t *testing.T) { modifier: func(cfg *Config) { cfg.L2ChainID = big.NewInt(0) }, expectedErr: ErrL2ChainIDNotPositive, }, + { + name: "EspressoEnabledWithoutBatchAuthenticatorAddress", + modifier: func(cfg *Config) { + espressoTime := uint64(1) + cfg.EspressoTime = &espressoTime + cfg.BatchAuthenticatorAddress = common.Address{} + }, + expectedErr: ErrMissingBatchAuthenticatorAddress, + }, + { + name: "EspressoEnabledWithBatchAuthenticatorAddress", + modifier: func(cfg *Config) { + espressoTime := uint64(1) + cfg.EspressoTime = &espressoTime + cfg.BatchAuthenticatorAddress = common.Address{0x01} + }, + expectedErr: nil, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/op-service/testutils/mock_eth_client.go b/op-service/testutils/mock_eth_client.go index 9975293866c..28ae160c144 100644 --- a/op-service/testutils/mock_eth_client.go +++ b/op-service/testutils/mock_eth_client.go @@ -148,6 +148,15 @@ func (m *MockEthClient) ExpectFetchReceipts(hash common.Hash, info eth.BlockInfo m.Mock.On("FetchReceipts", hash).Once().Return(&info, receipts, err) } +// SetFetchReceipts is like ExpectFetchReceipts, but registers an unbounded +// expectation (matches any number of FetchReceipts calls with the given hash, +// rather than exactly one). Useful when the derivation pipeline scans the same +// L1 block multiple times — for example when collecting BatchInfoAuthenticated +// events for batch authorization. +func (m *MockEthClient) SetFetchReceipts(hash common.Hash, info eth.BlockInfo, receipts types.Receipts, err error) { + m.Mock.On("FetchReceipts", hash).Return(&info, receipts, err) +} + func (m *MockEthClient) GetProof(ctx context.Context, address common.Address, storage []common.Hash, blockTag string) (*eth.AccountResult, error) { out := m.Mock.Called(address, storage, blockTag) return out.Get(0).(*eth.AccountResult), out.Error(1)