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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion op-node/rollup/derive/blob_data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D
var hashes []common.Hash
for _, tx := range txs {
// skip any non-batcher transactions (wrong type or wrong To address)
if !isValidBatchTx(tx, config.batchInboxAddress, logger) {
if !isBatchTxToInbox(tx, config.batchInboxAddress) {
continue
}

Expand Down
79 changes: 20 additions & 59 deletions op-node/rollup/derive/calldata_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,29 @@ type CalldataSource struct {
// Required to re-attempt fetching
ref eth.L1BlockRef
dsCfg DataSourceConfig
fetcher L1Fetcher
fetcher L1TransactionFetcher
log log.Logger

batcherAddr common.Address
}

// 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 L1Fetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter {
closedSource := &CalldataSource{
open: false,
ref: ref,
dsCfg: dsCfg,
fetcher: fetcher,
log: log,
batcherAddr: batcherAddr,
}

func NewCalldataSource(ctx context.Context, log log.Logger, dsCfg DataSourceConfig, fetcher L1TransactionFetcher, ref eth.L1BlockRef, batcherAddr common.Address) DataIter {
_, txs, err := fetcher.InfoAndTxsByHash(ctx, ref.Hash)
if err != nil {
return closedSource
}
data, err := DataFromEVMTransactions(ctx, dsCfg, batcherAddr, txs, fetcher, ref, log.New("origin", ref))
if err != nil {
return closedSource
return &CalldataSource{
open: false,
ref: ref,
dsCfg: dsCfg,
fetcher: fetcher,
log: log,
batcherAddr: batcherAddr,
}
}
return &CalldataSource{
open: true,
data: data,
data: DataFromEVMTransactions(dsCfg, batcherAddr, txs, log.New("origin", ref)),
}
}

Expand All @@ -61,17 +55,14 @@ 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 {
_, txs, err := ds.fetcher.InfoAndTxsByHash(ctx, ds.ref.Hash)
if errors.Is(err, ethereum.NotFound) {
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) {
return nil, NewResetError(fmt.Errorf("failed to open calldata source: %w", err))
} else if err != nil {
} else {
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
Expand All @@ -85,42 +76,12 @@ 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.
//
// 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
}
}

func DataFromEVMTransactions(dsCfg DataSourceConfig, batcherAddr common.Address, txs types.Transactions, log log.Logger) []eth.Data {
out := []eth.Data{}
for _, tx := range txs {
if !isValidBatchTx(tx, dsCfg.batchInboxAddress, log) {
continue
if isValidBatchTx(tx, dsCfg.l1Signer, dsCfg.batchInboxAddress, batcherAddr, log) {
out = append(out, tx.Data())
}

batchHash := ComputeCalldataBatchHash(tx.Data())
if !isBatchTxAuthorized(tx, dsCfg, batcherAddr, batchHash, authenticatedHashes, ref.Time, log) {
continue
}

out = append(out, tx.Data())
}
return out, nil
return out
}
17 changes: 3 additions & 14 deletions op-node/rollup/derive/calldata_source_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package derive

import (
"context"
"crypto/ecdsa"
"math/big"
"math/rand"
Expand Down Expand Up @@ -116,24 +115,14 @@ func TestDataFromEVMTransactions(t *testing.T) {
var expectedData []eth.Data
var txs []*types.Transaction
for i, tx := range tc.txs {
transaction := tx.Create(t, signer, rng)
txs = append(txs, transaction)

txs = append(txs, tx.Create(t, signer, rng))
if tx.good {
expectedData = append(expectedData, txs[i].Data())
}
}

// 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)
out := DataFromEVMTransactions(DataSourceConfig{l1Signer: cfg.L1Signer(), batchInboxAddress: cfg.BatchInboxAddress}, batcherAddr, txs, testlog.Logger(t, log.LevelCrit))
require.ElementsMatch(t, expectedData, out)
}

}
19 changes: 16 additions & 3 deletions op-node/rollup/derive/data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,26 @@ type DataSourceConfig struct {
batchAuthCaches *BatchAuthCaches
}

// isValidBatchTx checks basic transaction validity for batch submission:
// isValidBatchTx returns true if:
// 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
//
// This is the pre-Espresso (upstream) sender-based validity check used by the calldata data
// source. The blob data source instead composes isBatchTxToInbox with isBatchTxAuthorized so it
// can apply event-based authorization post-Espresso, where the submitter need not be the
// configured batcher address.
func isValidBatchTx(tx *types.Transaction, l1Signer types.Signer, batchInboxAddr, batcherAddr common.Address, logger log.Logger) bool {
return isBatchTxToInbox(tx, batchInboxAddr) && isAuthorizedBatchSender(tx, l1Signer, batcherAddr, logger)
}

// isBatchTxToInbox 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
//
// 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 {
// by isAuthorizedBatchSender / isBatchTxAuthorized.
func isBatchTxToInbox(tx *types.Transaction, batchInboxAddr common.Address) 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
Expand Down
157 changes: 154 additions & 3 deletions op-node/rollup/derive/espresso_blob_data_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,83 @@ import (
"github.com/ethereum/go-ethereum/log"
)

// 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
}

// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication for both
// calldata and blob transactions in the blob data source path.
//
Expand Down Expand Up @@ -201,6 +278,82 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) {
require.Equal(t, 0, len(blobHashes))
l1F.AssertExpectations(t)
})

t.Run("mixed block: only the event-authenticated tx accepted", func(t *testing.T) {
l1F := &testutils.MockL1Source{}
newInboxTx := func(key *ecdsa.PrivateKey) *types.Transaction {
tx, err := types.SignNewTx(key, signer, &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),
})
require.NoError(t, err)
return tx
}
// tx1: batcher-signed, commitment authenticated — accepted.
// tx2: batcher-signed, commitment NOT authenticated — rejected even though its
// sender authenticated tx1's commitment (each tx must be matched against its
// own commitment, not just any commitment from an authenticated caller).
// tx3: unknown sender, not authenticated — rejected.
tx1 := newInboxTx(privateKey)
tx2 := newInboxTx(privateKey)
tx3 := newInboxTx(altKey)

ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)}
ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr,
[]common.Hash{ComputeCalldataBatchHash(tx1.Data())})

data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx1, tx2, tx3}, &config, batcherAddr, l1F, ref, logger)
require.NoError(t, err)
require.Equal(t, 1, len(data), "only the event-authenticated tx should pass")
require.Equal(t, 0, len(blobHashes))
require.NotNil(t, data[0].calldata)
require.Equal(t, eth.Data(tx1.Data()), *data[0].calldata)
require.NotEqual(t, eth.Data(tx2.Data()), *data[0].calldata)
l1F.AssertExpectations(t)
})

t.Run("multiple authenticated txs each accepted for their own commitment", func(t *testing.T) {
// One calldata batch and one blob batch in the same block, each authenticated
// via its own commitment (calldata hash vs blob-hash concatenation). Both must
// be accepted and mapped to their own data.
l1F := &testutils.MockL1Source{}
calldataTx, _ := types.SignNewTx(privateKey, signer, &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),
})
blobHash := testutils.RandomHash(rng)
blobTx, _ := types.SignNewTx(privateKey, signer, &types.BlobTx{
Nonce: rng.Uint64(),
Gas: 2_000_000,
To: batchInboxAddr,
BlobHashes: []common.Hash{blobHash},
})

ref := eth.L1BlockRef{Number: 1, Hash: testutils.RandomHash(rng)}
ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, []common.Hash{
ComputeCalldataBatchHash(calldataTx.Data()),
ComputeBlobBatchHash([]common.Hash{blobHash}),
})

data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx, blobTx}, &config, batcherAddr, l1F, ref, logger)
require.NoError(t, err)
require.Equal(t, 2, len(data))
require.Equal(t, 1, len(blobHashes))
require.Equal(t, blobHash, blobHashes[0])
require.NotNil(t, data[0].calldata, "first entry must be the calldata batch")
require.Equal(t, eth.Data(calldataTx.Data()), *data[0].calldata)
require.Nil(t, data[1].calldata, "second entry must be the blob placeholder")
require.Nil(t, data[1].blob)
l1F.AssertExpectations(t)
})
}

// TestDataAndHashesFromTxsForkBoundary exercises the Espresso fork gate flipping in the
Expand All @@ -209,9 +362,7 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) {
// 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.
// scanning; at and after activation it must switch to event-based authentication.
func TestDataAndHashesFromTxsForkBoundary(t *testing.T) {
rng := rand.New(rand.NewSource(7777))
privateKey := testutils.InsecureRandomKey(rng)
Expand Down
Loading
Loading