From 72c34af2a76f2ba01fb1694a1303baa623e07475 Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Tue, 26 May 2026 18:10:31 +0200 Subject: [PATCH 01/13] op-service: add ChainSigner interface and eth_sign helper Bring in op-service/crypto/espresso.go (ChainSigner interface unifying SignTransaction and arbitrary-data signing) and op-service/signer/espresso.go (SignerClient.Sign wrapper around eth_sign). Required by the Espresso batcher to sign batch-authentication payloads with either a remote signer or a local private key, in addition to the existing transaction-signing path. Co-authored-by: OpenCode --- op-service/crypto/espresso.go | 168 +++++++++++++++++++++++++++++ op-service/crypto/espresso_test.go | 134 +++++++++++++++++++++++ op-service/signer/espresso.go | 18 ++++ 3 files changed, 320 insertions(+) create mode 100644 op-service/crypto/espresso.go create mode 100644 op-service/crypto/espresso_test.go create mode 100644 op-service/signer/espresso.go diff --git a/op-service/crypto/espresso.go b/op-service/crypto/espresso.go new file mode 100644 index 00000000000..b61e4b6fff1 --- /dev/null +++ b/op-service/crypto/espresso.go @@ -0,0 +1,168 @@ +package crypto + +import ( + "bytes" + "context" + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "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" + + hdwallet "github.com/ethereum-optimism/go-ethereum-hdwallet" + opsigner "github.com/ethereum-optimism/optimism/op-service/signer" +) + +// ChainSignerFactory creates a SignerFn that is bound to a specific ChainID +type ChainSignerFactory func(chainID *big.Int, from common.Address) ChainSigner + +// ChainSigner is a generic interface for signing transactions or arbitrary data. +type ChainSigner interface { + + // SignTransaction signs a transaction with the given address. + SignTransaction(ctx context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) + + // Sign signs the hash of arbitrary data. + Sign(ctx context.Context, hash []byte) ([]byte, error) +} + +// clientSigner is a ChainSigner that utilizes a remote signer to perform +// Sign and SignTransaction +type clientSigner struct { + signerClient *opsigner.SignerClient + fromAddress common.Address + chainID *big.Int +} + +// Sign implements Signer. +func (c *clientSigner) Sign(ctx context.Context, data []byte) ([]byte, error) { + return c.signerClient.Sign(ctx, c.fromAddress, data) +} + +// VerifySignature verifies that the signature was produced by the expected address. +// data is the original message (e.g., txdata.CallData()) and signature is the result +// from eth_sign. +func Verify(data []byte, signature []byte, expected common.Address) error { + + pubKey, err := crypto.SigToPub(data, signature) + if err != nil { + return fmt.Errorf("failed to recover public key: %w", err) + } + + // Convert the ecdsa.PublicKey to an Address + address := crypto.PubkeyToAddress(*pubKey) + + // Ensure that the derived address matches the expected address. + if !bytes.Equal(address.Bytes(), expected.Bytes()) { + return fmt.Errorf("address mismatch: got %s, expected %s", address.Hex(), expected.Hex()) + } + + return nil +} + +// SignTransaction implements Signer. +func (c *clientSigner) SignTransaction(ctx context.Context, address common.Address, tx *types.Transaction) (*types.Transaction, error) { + if !bytes.Equal(address[:], c.fromAddress[:]) { + return nil, fmt.Errorf("attempting to sign for %s, expected %s: ", address, c.fromAddress) + } + return c.signerClient.SignTransaction(ctx, c.chainID, address, tx) +} + +var _ ChainSigner = &clientSigner{} + +// privateKeySigner is a ChainSigner that delegates to the stored +// functions for performing Sign and SignTransaction. In general these stored +// functions are expected to have access to a private key that is not +// explicitly stored within the structure itself. +type privateKeySigner struct { + chainID *big.Int + st bind.SignerFn + fromAddress common.Address + s func(common.Address, []byte) ([]byte, error) +} + +// Sign implements Signer. +func (p *privateKeySigner) Sign(ctx context.Context, hash []byte) ([]byte, error) { + return p.s(p.fromAddress, hash) +} + +// SignTransaction implements Signer. +func (p *privateKeySigner) SignTransaction(ctx context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) { + return p.st(addr, tx) +} + +var _ ChainSigner = &privateKeySigner{} + +// ChainSignerFactoryFromConfig considers three ways that signers are created & then creates single factory from those config options. +// It can either take a remote signer (via opsigner.CLIConfig) or it can be provided either a mnemonic + derivation path or a private key. +// It prefers the remote signer, then the mnemonic or private key (only one of which can be provided). +func ChainSignerFactoryFromConfig(l log.Logger, privateKey, mnemonic, hdPath string, signerConfig opsigner.CLIConfig) (ChainSignerFactory, common.Address, error) { + var signer ChainSignerFactory + var fromAddress common.Address + if signerConfig.Enabled() { + signerClient, err := opsigner.NewSignerClientFromConfig(l, signerConfig) + if err != nil { + l.Error("Unable to create Signer Client", "error", err) + return nil, common.Address{}, fmt.Errorf("failed to create the signer client: %w", err) + } + fromAddress = common.HexToAddress(signerConfig.Address) + signer = func(chainID *big.Int, _ common.Address) ChainSigner { + return &clientSigner{ + signerClient: signerClient, + fromAddress: fromAddress, + chainID: chainID, + } + } + } else { + var privKey *ecdsa.PrivateKey + var err error + + if privateKey != "" && mnemonic != "" { + return nil, common.Address{}, errors.New("cannot specify both a private key and a mnemonic") + } + if privateKey == "" { + // Parse l2output wallet private key and L2OO contract address. + wallet, err := hdwallet.NewFromMnemonic(mnemonic) + if err != nil { + return nil, common.Address{}, fmt.Errorf("failed to parse mnemonic: %w", err) + } + + privKey, err = wallet.PrivateKey(accounts.Account{ + URL: accounts.URL{ + Path: hdPath, + }, + }) + if err != nil { + return nil, common.Address{}, fmt.Errorf("failed to create a wallet: %w", err) + } + } else { + privKey, err = crypto.HexToECDSA(strings.TrimPrefix(privateKey, "0x")) + if err != nil { + return nil, common.Address{}, fmt.Errorf("failed to parse the private key: %w", err) + } + } + // we force the curve to Geth's instance, because Geth does an equality check in the nocgo version: + // https://github.com/ethereum/go-ethereum/blob/723b1e36ad6a9e998f06f74cc8b11d51635c6402/crypto/signature_nocgo.go#L82 + privKey.PublicKey.Curve = crypto.S256() + fromAddress = crypto.PubkeyToAddress(privKey.PublicKey) + signer = func(chainID *big.Int, from common.Address) ChainSigner { + s := PrivateKeySignerFn(privKey, chainID) + return &privateKeySigner{ + chainID: chainID, + st: s, + s: func(addr common.Address, hash []byte) ([]byte, error) { + return crypto.Sign(hash, privKey) + }, + } + } + } + + return signer, fromAddress, nil +} diff --git a/op-service/crypto/espresso_test.go b/op-service/crypto/espresso_test.go new file mode 100644 index 00000000000..8d42df96f8b --- /dev/null +++ b/op-service/crypto/espresso_test.go @@ -0,0 +1,134 @@ +package crypto + +import ( + "testing" + + "context" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-service/signer" + "github.com/ethereum-optimism/optimism/op-service/testlog" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" +) + +// should be run with CGO_ENABLED=0 + +func TestVerify(t *testing.T) { + // happy path + batcherSignature := []byte{ + 109, 206, 105, 108, 152, 110, 156, 111, 239, 153, 224, 182, 140, 49, 105, 120, + 153, 163, 162, 47, 119, 34, 68, 128, 118, 33, 143, 79, 101, 212, 75, 161, + 124, 77, 236, 159, 70, 167, 95, 51, 92, 127, 236, 253, 4, 211, 222, 117, + 54, 27, 214, 232, 135, 87, 33, 77, 16, 155, 164, 116, 220, 116, 31, 208, 1, + } + sequencerBatchesByte := []byte{ + 166, 136, 91, 55, 49, 112, 45, 166, + 46, 142, 74, 143, 88, 74, 196, 106, + 127, 104, 34, 244, 226, 186, 80, 251, + 169, 2, 246, 123, 21, 136, 210, 59, + } + + expected := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + err := Verify(sequencerBatchesByte, batcherSignature, expected) + require.NoError(t, err) + + // wrong length batcher signature + wrongLengthBatcherSignature := []byte{ + 1, + } + err = Verify(sequencerBatchesByte, wrongLengthBatcherSignature, expected) + // check it returns an correct error: address mismatch + require.Error(t, err) + require.Contains(t, err.Error(), "failed to recover public key: invalid signature length") + + // wrong batcher signature + wrongBatcherSignature := []byte{ + 1, 1, 1, 1, 152, 110, 156, 111, 239, 153, 224, 182, 140, 49, 105, 120, + 153, 163, 162, 47, 119, 34, 68, 128, 118, 33, 143, 79, 101, 212, 75, 161, + 124, 77, 236, 159, 70, 167, 95, 51, 92, 127, 236, 253, 4, 211, 222, 117, + 54, 27, 214, 232, 135, 87, 33, 77, 16, 155, 164, 116, 220, 116, 31, 208, 1, + } + err = Verify(sequencerBatchesByte, wrongBatcherSignature, expected) + // check it returns an correct error: address mismatch + require.Error(t, err) + require.Contains(t, err.Error(), "address mismatch") + + // wrong expected address + wrongExpected := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C9") + err = Verify(sequencerBatchesByte, batcherSignature, wrongExpected) + require.Error(t, err) + require.Contains(t, err.Error(), "address mismatch") + +} + +func TestChainSignerFactoryFromMnemonic(t *testing.T) { + mnemonic := "test test test test test test test test test test test junk" + hdPath := "m/44'/60'/0'/0/1" + testChainSignerSignTransaction(t, "", mnemonic, hdPath, signer.CLIConfig{}) + testChainSignerSign(t, "", mnemonic, hdPath, signer.CLIConfig{}) +} + +func TestChainSignerFactoryFromKey(t *testing.T) { + priv := "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + testChainSignerSignTransaction(t, priv, "", "", signer.CLIConfig{}) + testChainSignerSign(t, priv, "", "", signer.CLIConfig{}) +} + +func testChainSignerSignTransaction(t *testing.T, priv, mnemonic, hdPath string, cfg signer.CLIConfig) { + logger := testlog.Logger(t, log.LevelDebug) + + factoryFn, addr, err := ChainSignerFactoryFromConfig(logger, priv, mnemonic, hdPath, cfg) + require.NoError(t, err) + expectedAddr := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + require.Equal(t, expectedAddr, addr) + chainID := big.NewInt(10) + chainSigner := factoryFn(chainID, addr) // for chain ID 10 + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1), + Gas: 21000, + To: nil, + Value: big.NewInt(0), + Data: []byte("test"), + }) + signedTx, err := chainSigner.SignTransaction(context.Background(), addr, tx) + require.NoError(t, err) + gethSigner := types.LatestSignerForChainID(chainID) + sender, err := gethSigner.Sender(signedTx) + require.NoError(t, err) + require.Equal(t, expectedAddr, sender) +} + +func testChainSignerSign(t *testing.T, priv, mnemonic, hdPath string, cfg signer.CLIConfig) { + logger := testlog.Logger(t, log.LevelDebug) + + factoryFn, addr, err := ChainSignerFactoryFromConfig(logger, priv, mnemonic, hdPath, cfg) + require.NoError(t, err) + expectedAddr := common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + require.Equal(t, expectedAddr, addr) + chainID := big.NewInt(10) + chainSigner := factoryFn(chainID, addr) // for chain ID 10 + + payload := []byte{0x01, 0x02, 0x03, 0x04} + hash := crypto.Keccak256(payload) + signed, err := chainSigner.Sign(context.Background(), hash) + require.NoError(t, err) + + // Recover the public key from the signature and hash + pubKey, err := crypto.SigToPub(hash, signed) + require.NoError(t, err) + + // Convert the ecdsa.PublicKey to an Address + address := crypto.PubkeyToAddress(*pubKey) + + // Ensure that the derived address matches the expected address. + require.Equal(t, expectedAddr, address) +} diff --git a/op-service/signer/espresso.go b/op-service/signer/espresso.go new file mode 100644 index 00000000000..f5895de6473 --- /dev/null +++ b/op-service/signer/espresso.go @@ -0,0 +1,18 @@ +package signer + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// Sign represents the interface for signing things via eth_sign. +func (s *SignerClient) Sign(ctx context.Context, address common.Address, data []byte) ([]byte, error) { + var result hexutil.Bytes + if err := s.client.CallContext(ctx, &result, "eth_sign", address, data); err != nil { + return nil, fmt.Errorf("eth_sign failed: %w", err) + } + return result, nil +} From fbf0c6429592ebc9aab710e82708759481f740df Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Tue, 26 May 2026 18:12:52 +0200 Subject: [PATCH 02/13] op-node: add EspressoBatch type and converters Introduce op-node/rollup/derive/espresso_batch.go defining EspressoBatch (a SingularBatch with block number, L1 info deposit transaction, and signer address attached), along with BlockToEspressoBatch and the unmarshaler used by the streamer. Also pulls in the github.com/EspressoSystems/espresso-network/sdks/go dependency, which provides the Espresso transaction and namespace types. Consumed by the Espresso batcher (next commits) to convert L2 blocks into batches submitted to Espresso, and to round-trip those batches back through the streamer. Co-authored-by: OpenCode --- go.mod | 6 +- go.sum | 4 + op-node/rollup/derive/espresso_batch.go | 138 +++++++++++ op-node/rollup/derive/espresso_batch_test.go | 237 +++++++++++++++++++ 4 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 op-node/rollup/derive/espresso_batch.go create mode 100644 op-node/rollup/derive/espresso_batch_test.go diff --git a/go.mod b/go.mod index a51b969a148..a4e20093b5a 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.13 require ( github.com/BurntSushi/toml v1.5.0 + github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513 github.com/Masterminds/semver/v3 v3.3.1 github.com/andybalholm/brotli v1.1.0 github.com/base/go-bip39 v1.1.0 @@ -69,7 +70,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect +require ( + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f // indirect +) require ( github.com/benbjohnson/immutable v0.4.0 // indirect diff --git a/go.sum b/go.sum index e3f1c2994ab..23020ded907 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.5.6-0.20230824185856-869dae002e5e h1:ZIWapoIRN1VqT8GR8jAwb1Ie9GyehWjVcGh32Y2MznE= github.com/DataDog/zstd v1.5.6-0.20230824185856-869dae002e5e/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513 h1:4KqlmnDg8pPPj1hCtiLnN0Fh9ltp+PPKOft9I06zQOA= +github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513/go.mod h1:kaxR08mJb5Mijy7a2RhWCIWOevFI4PcXwDkzoEbsVTk= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -823,6 +825,8 @@ github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go. github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= +github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= diff --git a/op-node/rollup/derive/espresso_batch.go b/op-node/rollup/derive/espresso_batch.go new file mode 100644 index 00000000000..fc22780bfd5 --- /dev/null +++ b/op-node/rollup/derive/espresso_batch.go @@ -0,0 +1,138 @@ +package derive + +import ( + "bytes" + "context" + "fmt" + + espressoCommon "github.com/EspressoSystems/espresso-network/sdks/go/types" + "github.com/ethereum-optimism/optimism/op-node/rollup" + opCrypto "github.com/ethereum-optimism/optimism/op-service/crypto" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" +) + +// A SingularBatch with block number attached to restore ordering +// when fetching from Espresso +type EspressoBatch struct { + BatchHeader *types.Header + Batch SingularBatch + L1InfoDeposit *types.Transaction + SignerAddress common.Address +} + +func (b EspressoBatch) Number() uint64 { + return b.BatchHeader.Number.Uint64() +} + +func (b EspressoBatch) L1Origin() eth.BlockID { + return b.Batch.Epoch() +} + +func (b EspressoBatch) Header() *types.Header { + return b.BatchHeader +} + +func (b EspressoBatch) Hash() common.Hash { + hash := crypto.Keccak256Hash(b.BatchHeader.Hash().Bytes(), b.L1InfoDeposit.Hash().Bytes()) + return hash +} + +func (b EspressoBatch) Signer() common.Address { + return b.SignerAddress +} + +func (b *EspressoBatch) ToEspressoTransaction(ctx context.Context, namespace uint64, signer opCrypto.ChainSigner) (*espressoCommon.Transaction, error) { + buf := new(bytes.Buffer) + err := rlp.Encode(buf, *b) + if err != nil { + return nil, fmt.Errorf("failed to encode batch: %w", err) + } + + batcherSignature, err := signer.Sign(ctx, crypto.Keccak256(buf.Bytes())) + + if err != nil { + return nil, fmt.Errorf("failed to create batcher signature: %w", err) + } + + payload := append(batcherSignature, buf.Bytes()...) + + return &espressoCommon.Transaction{Namespace: namespace, Payload: payload}, nil + +} + +func BlockToEspressoBatch(rollupCfg *rollup.Config, block *types.Block) (*EspressoBatch, error) { + if len(block.Transactions()) == 0 { + return nil, fmt.Errorf("Block doesn't contain any transactions") + } + + l1InfoDeposit := block.Transactions()[0] + if !l1InfoDeposit.IsDepositTx() { + return nil, fmt.Errorf("First transaction is not L1 info deposit") + } + + batch, _, err := BlockToSingularBatch(rollupCfg, block) + if err != nil { + return nil, err + } + + return &EspressoBatch{ + BatchHeader: block.Header(), + Batch: *batch, + L1InfoDeposit: l1InfoDeposit, + }, nil +} + +// CreateEspressoBatchUnmarshaler returns a function that can be used to +// unmarshal an Espresso transaction into an EspressoBatch. +// The signer address is recovered from the signature and stored on the batch +// for later verification in CheckBatch (two-phase verification). +func CreateEspressoBatchUnmarshaler() func(data []byte) (*EspressoBatch, error) { + return func(data []byte) (*EspressoBatch, error) { + return UnmarshalEspressoTransaction(data) + } +} + +func UnmarshalEspressoTransaction(data []byte) (*EspressoBatch, error) { + if len(data) < crypto.SignatureLength { + return nil, fmt.Errorf("transaction data too short: %d bytes, need at least %d", len(data), crypto.SignatureLength) + } + signatureData, batchData := data[:crypto.SignatureLength], data[crypto.SignatureLength:] + batchHash := crypto.Keccak256(batchData) + + signerKey, err := crypto.SigToPub(batchHash, signatureData) + if err != nil { + return nil, err + } + signer := crypto.PubkeyToAddress(*signerKey) + + var batch EspressoBatch + if err := rlp.DecodeBytes(batchData, &batch); err != nil { + return nil, err + } + batch.SignerAddress = signer + + return &batch, nil +} + +// NOTE: This function MUST guarantee no transient errors. It is allowed to fail only on +// invalid batches or in case of misconfiguration of the batcher, in which case it should fail +// for all batches. +func (b *EspressoBatch) ToBlock(rollupCfg *rollup.Config) (*types.Block, error) { + // Re-insert the deposit transaction + txs := []*types.Transaction{b.L1InfoDeposit} + for i, opaqueTx := range b.Batch.Transactions { + var tx types.Transaction + err := tx.UnmarshalBinary(opaqueTx) + if err != nil { + return nil, fmt.Errorf("could not decode tx %d: %w", i, err) + } + txs = append(txs, &tx) + } + return types.NewBlockWithHeader(b.BatchHeader).WithBody(types.Body{ + Transactions: txs, + }), nil +} diff --git a/op-node/rollup/derive/espresso_batch_test.go b/op-node/rollup/derive/espresso_batch_test.go new file mode 100644 index 00000000000..05ef9c2f9d3 --- /dev/null +++ b/op-node/rollup/derive/espresso_batch_test.go @@ -0,0 +1,237 @@ +package derive_test + +import ( + "bytes" + "math/big" + "math/rand" + "slices" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + derive "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + dtest "github.com/ethereum-optimism/optimism/op-node/rollup/derive/test" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +var defaultTestRollUpConfig = &rollup.Config{ + Genesis: rollup.Genesis{L2: eth.BlockID{Number: 0}}, + L2ChainID: big.NewInt(1234), +} + +// compareHash is a helper function that compares two hashes. +func compareHash(a, b common.Hash) int { + if c := bytes.Compare(a[:], b[:]); c != 0 { + return c + } + return 0 +} + +// compareTransaction is a helper function that compares two transactions +// by only inspecting their hashes. +func compareTransaction(a, b *gethTypes.Transaction) int { + return compareHash(a.Hash(), b.Hash()) +} + +// compareHeader is a helper function that compares two headers +// by only inspecting their hashes. +func compareHeader(a, b *gethTypes.Header) int { + return compareHash(a.Hash(), b.Hash()) +} + +// compareWithdrawl is a helper function that compares two withdrawals +// by checking that their slice members compare equivalently. +func compareWithdrawl(a, b *gethTypes.Withdrawal) int { + if c := a.Index - b.Index; c != 0 { + return int(c) + } + + if c := a.Validator - b.Validator; c != 0 { + return int(c) + } + + if c := a.Address.Cmp(b.Address); c != 0 { + return c + } + + if c := a.Amount - b.Amount; c != 0 { + return int(c) + } + + return 0 +} + +// compareBody is a helper function that compares two bodies +// by checking that their slice members compare equivalently. +func compareBody(a, b *gethTypes.Body) int { + if c := slices.CompareFunc(a.Transactions, b.Transactions, compareTransaction); c != 0 { + return c + } + + if c := slices.CompareFunc(a.Uncles, b.Uncles, compareHeader); c != 0 { + return c + } + + if c := slices.CompareFunc(a.Withdrawals, b.Withdrawals, compareWithdrawl); c != 0 { + return c + } + + return 0 +} + +// TestUnmarshalEspressoTransactionTooShort verifies that UnmarshalEspressoTransaction +// returns an error (rather than panicking) when the input is shorter than a signature. +func TestUnmarshalEspressoTransactionTooShort(t *testing.T) { + cases := [][]byte{ + nil, + {}, + make([]byte, crypto.SignatureLength-1), + } + for _, data := range cases { + _, err := derive.UnmarshalEspressoTransaction(data) + require.Error(t, err, "expected error for %d-byte input", len(data)) + } +} + +// TestEspressoBatchConversion tests the conversion of a block to an Espresso +// Batch, and ensures that the recovery of the original Block is possible with +// the contents of the Espresso Batch. +func TestEspressoBatchConversion(t *testing.T) { + rng := rand.New(rand.NewSource(4982432)) + ti := time.Now() + + originalBlock := dtest.RandomL2BlockWithChainIdAndTime(rng, rng.Intn(32), defaultTestRollUpConfig.L2ChainID, ti) + + espressoBatch, err := derive.BlockToEspressoBatch(defaultTestRollUpConfig, originalBlock) + if have, want := err, error(nil); have != want { + t.Fatalf("failed to convert block to batch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + decodedBlock, err := espressoBatch.ToBlock(defaultTestRollUpConfig) + if have, want := err, error(nil); have != want { + t.Fatalf("failed to decode batch back to block:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + // Let's perform a sanity check on the decoded block to ensure that all of + // the fields match the original block. + + if have, want := decodedBlock.BaseFee(), originalBlock.BaseFee(); have.Cmp(want) != 0 { + t.Errorf("decoded block base fee mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.BeaconRoot(), originalBlock.BeaconRoot(); have != want { + t.Errorf("decoded block beacon root mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.BlobGasUsed(), originalBlock.BlobGasUsed(); have != want { + t.Errorf("decoded block blob gas used mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Bloom(), originalBlock.Bloom(); have != want { + t.Errorf("decoded block bloom mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Body(), originalBlock.Body(); compareBody(have, want) != 0 { + t.Errorf("decoded block body mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Coinbase(), originalBlock.Coinbase(); have != want { + t.Errorf("decoded block coinbase mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Difficulty(), originalBlock.Difficulty(); have.Cmp(want) != 0 { + t.Errorf("decoded block difficulty mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.ExcessBlobGas(), originalBlock.ExcessBlobGas(); have != want { + t.Errorf("decoded block excess blob gas mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.ExecutionWitness(), originalBlock.ExecutionWitness(); have != want { + t.Errorf("decoded block execution witness mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Extra(), originalBlock.Extra(); !bytes.Equal(have, want) { + t.Errorf("decoded block extra mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.GasLimit(), originalBlock.GasLimit(); have != want { + t.Errorf("decoded block gas limit mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.GasUsed(), originalBlock.GasUsed(); have != want { + t.Errorf("decoded block gas used mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Hash(), originalBlock.Hash(); have != want { + t.Errorf("decoded block hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Header(), originalBlock.Header(); compareHeader(have, want) != 0 { + t.Errorf("decoded block header mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.MixDigest(), originalBlock.MixDigest(); have != want { + t.Errorf("decoded block mix digest mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Nonce(), originalBlock.Nonce(); have != want { + t.Errorf("decoded block nonce mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Number(), originalBlock.Number(); have.Cmp(want) != 0 { + t.Errorf("decoded block number mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.NumberU64(), originalBlock.NumberU64(); have != want { + t.Errorf("decoded block number u64 mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.ParentHash(), originalBlock.ParentHash(); have != want { + t.Errorf("decoded block parent hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.ReceiptHash(), originalBlock.ReceiptHash(); have != want { + t.Errorf("decoded block receipt hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.RequestsHash(), originalBlock.RequestsHash(); have != want { + t.Errorf("decoded block requests hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Root(), originalBlock.Root(); have != want { + t.Errorf("decoded block root mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Size(), originalBlock.Size(); have != want { + t.Errorf("decoded block size mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Time(), originalBlock.Time(); have != want { + t.Errorf("decoded block time mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Transactions(), originalBlock.Transactions(); slices.CompareFunc(have, want, compareTransaction) != 0 { + t.Errorf("decoded block transactions mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.TxHash(), originalBlock.TxHash(); have != want { + t.Errorf("decoded block tx hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.UncleHash(), originalBlock.UncleHash(); have != want { + t.Errorf("decoded block uncle hash mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.Withdrawals(), originalBlock.Withdrawals(); slices.CompareFunc(have, want, compareWithdrawl) != 0 { + t.Errorf("decoded block withdrawals mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } + + if have, want := decodedBlock.WithdrawalsRoot(), originalBlock.WithdrawalsRoot(); have != want { + t.Errorf("decoded block withdrawals root mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) + } +} From bb9546723a07fe03ac320483d10b8c72068e0a0e Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Wed, 27 May 2026 15:54:53 +0200 Subject: [PATCH 03/13] espresso: add TEE batcher CLI flags, streamer interface, and L1 adapter Bring in the parts of the espresso/ shared package that are TEE-only: - espresso/cli.go: full CLI flag set for --espresso.enabled, query service URLs, light-client/L1 endpoints, batch-authenticator address, receipt-verification tuning, namespace/origin-height parameters used to construct the espresso-streamer. (The --espresso.fallback-auth-lead-time flag lives in op-batcher/flags/flags.go and was added by the fallback PR.) - espresso/interface.go: EspressoStreamer[B] interface that wraps github.com/EspressoSystems/espresso-streamers/op.BatchStreamer. - espresso/ethclient.go: AdaptL1BlockRefClient adapter (used by cli.go to construct the streamer) and FetchEspressoBatcherAddress helper. Also adds the EspressoSystems/espresso-network/sdks/go and EspressoSystems/espresso-streamers Go module dependencies. The regenerated BatchAuthenticator bindings already live in the fallback PR's espresso/bindings/. Co-authored-by: OpenCode --- espresso/cli.go | 306 ++++++++++++++++++++++++++++++++++++++++++ espresso/ethclient.go | 60 +++++++++ espresso/interface.go | 77 +++++++++++ go.mod | 5 +- go.sum | 6 +- 5 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 espresso/cli.go create mode 100644 espresso/ethclient.go create mode 100644 espresso/interface.go diff --git a/espresso/cli.go b/espresso/cli.go new file mode 100644 index 00000000000..9d1772ecf53 --- /dev/null +++ b/espresso/cli.go @@ -0,0 +1,306 @@ +package espresso + +import ( + "crypto/ecdsa" + "fmt" + "strings" + "time" + + op "github.com/EspressoSystems/espresso-streamers/op" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + + "github.com/urfave/cli/v2" + + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + espressoLightClient "github.com/EspressoSystems/espresso-network/sdks/go/light-client" +) + +// espressoFlags returns the flag names for espresso +func espressoFlags(v string) string { + return "espresso." + v +} + +func espressoEnvs(envprefix, v string) []string { + return []string{envprefix + "_ESPRESSO_" + v} +} + +// Default values for batch submission receipt verification tuning. +// Defined here so that both the CLI flag defaults and the batcher logic +// can reference a single source of truth. +// +// Note: DefaultBatchAuthLookbackWindow lives in constants.go (mips64-clean +// build target shared with the derivation pipeline). +const ( + DefaultVerifyReceiptMaxBlocks uint64 = 5 + DefaultVerifyReceiptSafetyTimeout time.Duration = 5 * time.Minute + DefaultVerifyReceiptRetryDelay time.Duration = 100 * time.Millisecond + DefaultMaxInFlightRequestsToEspresso = 128 +) + +var ( + EnabledFlagName = espressoFlags("enabled") + PollIntervalFlagName = espressoFlags("poll-interval") + QueryServiceUrlsFlagName = espressoFlags("urls") + LightClientAddrFlagName = espressoFlags("light-client-addr") + L1UrlFlagName = espressoFlags("l1-url") + TestingBatcherPrivateKeyFlagName = espressoFlags("testing-batcher-private-key") + CaffeinationHeightEspresso = espressoFlags("origin-height-espresso") + CaffeinationHeightL2 = espressoFlags("origin-height-l2") + NamespaceFlagName = espressoFlags("namespace") + RollupL1UrlFlagName = espressoFlags("rollup-l1-url") + AttestationServiceFlagName = espressoFlags("espresso-attestation-service") + BatchAuthenticatorAddrFlagName = espressoFlags("batch-authenticator-addr") + VerifyReceiptMaxBlocksFlagName = espressoFlags("verify-receipt-max-blocks") + VerifyReceiptSafetyTimeoutFlagName = espressoFlags("verify-receipt-safety-timeout") + VerifyReceiptRetryDelayFlagName = espressoFlags("verify-receipt-retry-delay") +) + +func CLIFlags(envPrefix string, category string) []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: EnabledFlagName, + Usage: "Enable Espresso mode", + Value: false, + EnvVars: espressoEnvs(envPrefix, "ENABLED"), + Category: category, + }, + &cli.DurationFlag{ + Name: PollIntervalFlagName, + Usage: "Polling interval for Espresso queries", + Value: 250 * time.Millisecond, + EnvVars: espressoEnvs(envPrefix, "POLL_INTERVAL"), + Category: category, + }, + &cli.StringSliceFlag{ + Name: QueryServiceUrlsFlagName, + Usage: "Comma-separated list of Espresso query service URLs", + EnvVars: espressoEnvs(envPrefix, "URLS"), + Category: category, + }, + &cli.StringFlag{ + Name: LightClientAddrFlagName, + Usage: "Address of the Espresso light client", + EnvVars: espressoEnvs(envPrefix, "LIGHT_CLIENT_ADDR"), + Category: category, + }, + &cli.StringFlag{ + Name: L1UrlFlagName, + Usage: "L1 RPC URL Espresso contracts are deployed on", + EnvVars: espressoEnvs(envPrefix, "L1_URL"), + Category: category, + }, + &cli.StringFlag{ + Name: TestingBatcherPrivateKeyFlagName, + Usage: "Pre-approved batcher ephemeral key (testing only)", + EnvVars: espressoEnvs(envPrefix, "TESTING_BATCHER_PRIVATE_KEY"), + Category: category, + }, + &cli.Uint64Flag{ + Name: CaffeinationHeightEspresso, + Usage: "Espresso transactions below this height will not be considered", + EnvVars: espressoEnvs(envPrefix, "ORIGIN_HEIGHT_ESPRESSO"), + Category: category, + }, + &cli.Uint64Flag{ + Name: CaffeinationHeightL2, + Usage: "L2 batch position at which the Espresso streamer starts emitting batches. " + + "Operational parameter for restarting batchers mid-chain. " + + "When zero, the streamer falls back to its internal default. " + + "Independent of the EspressoTime hardfork, which gates derivation semantics.", + Value: 0, + EnvVars: espressoEnvs(envPrefix, "ORIGIN_HEIGHT_L2"), + Category: category, + }, + &cli.Uint64Flag{ + Name: NamespaceFlagName, + Usage: "Namespace of Espresso transactions", + EnvVars: espressoEnvs(envPrefix, "NAMESPACE"), + Category: category, + }, + &cli.StringFlag{ + Name: RollupL1UrlFlagName, + Usage: "RPC URL of L1 backing the Rollup we're streaming for", + EnvVars: espressoEnvs(envPrefix, "ROLLUP_L1_URL"), + Category: category, + }, + &cli.StringFlag{ + Name: AttestationServiceFlagName, + Usage: "URL of the Espresso attestation service", + EnvVars: espressoEnvs(envPrefix, "ESPRESSO_ATTESTATION_SERVICE"), + Category: category, + }, + &cli.StringFlag{ + Name: BatchAuthenticatorAddrFlagName, + Usage: "Address of the Batch Authenticator contract", + EnvVars: espressoEnvs(envPrefix, "BATCH_AUTHENTICATOR_ADDR"), + Category: category, + }, + &cli.Uint64Flag{ + Name: VerifyReceiptMaxBlocksFlagName, + Usage: "Number of HotShot blocks to wait for a submitted transaction to become queryable before re-submitting", + Value: DefaultVerifyReceiptMaxBlocks, + EnvVars: espressoEnvs(envPrefix, "VERIFY_RECEIPT_MAX_BLOCKS"), + Category: category, + }, + &cli.DurationFlag{ + Name: VerifyReceiptSafetyTimeoutFlagName, + Usage: "Wall-clock backstop for receipt verification; re-submits the transaction if this duration is exceeded", + Value: DefaultVerifyReceiptSafetyTimeout, + EnvVars: espressoEnvs(envPrefix, "VERIFY_RECEIPT_SAFETY_TIMEOUT"), + Category: category, + }, + &cli.DurationFlag{ + Name: VerifyReceiptRetryDelayFlagName, + Usage: "Delay between receipt verification retries", + Value: DefaultVerifyReceiptRetryDelay, + EnvVars: espressoEnvs(envPrefix, "VERIFY_RECEIPT_RETRY_DELAY"), + Category: category, + }, + // Note: --espresso.fallback-auth-lead-time is registered by the + // fallback batcher in op-batcher/flags/flags.go; it is read by both + // the fallback and the TEE batcher paths. + } +} + +type CLIConfig struct { + Enabled bool + PollInterval time.Duration + QueryServiceURLs []string + LightClientAddr common.Address + BatchAuthenticatorAddr common.Address + L1URL string + RollupL1URL string + TestingBatcherPrivateKey *ecdsa.PrivateKey + Namespace uint64 + CaffeinationHeightEspresso uint64 + CaffeinationHeightL2 uint64 + EspressoAttestationService string + + // Batch submission receipt verification tuning + VerifyReceiptMaxBlocks uint64 + VerifyReceiptSafetyTimeout time.Duration + VerifyReceiptRetryDelay time.Duration + + // Non directly configurable option + allowEmptyAttestationService bool `json:"-"` +} + +// AllowEmptyAttestationService allows the attestation service URL to be +// empty. This is set explicitly from a public method, and isn't derivable +// from serialization or any other form other than this method. This allows +// this setting to be configured via the code, but not externally. +func (c *CLIConfig) AllowEmptyAttestationService() { + c.allowEmptyAttestationService = true +} + +func (c CLIConfig) Check() error { + if c.Enabled { + // Check required fields when Espresso is enabled + if len(c.QueryServiceURLs) == 0 { + return fmt.Errorf("query service URLs are required when Espresso is enabled") + } + if c.LightClientAddr == (common.Address{}) { + return fmt.Errorf("light client address is required when Espresso is enabled") + } + if c.L1URL == "" { + return fmt.Errorf("L1 URL is required when Espresso is enabled") + } + if c.RollupL1URL == "" { + return fmt.Errorf("rollup L1 URL is required when Espresso is enabled") + } + if c.Namespace == 0 { + return fmt.Errorf("namespace is required when Espresso is enabled") + } + if !c.allowEmptyAttestationService && c.EspressoAttestationService == "" { + return fmt.Errorf("attestation service URL is required when Espresso is enabled") + } + if c.VerifyReceiptMaxBlocks == 0 { + return fmt.Errorf("verify-receipt-max-blocks must be > 0") + } + if c.VerifyReceiptSafetyTimeout <= 0 { + return fmt.Errorf("verify-receipt-safety-timeout must be > 0") + } + if c.VerifyReceiptRetryDelay <= 0 { + return fmt.Errorf("verify-receipt-retry-delay must be > 0") + } + } + return nil +} + +func ReadCLIConfig(c *cli.Context) CLIConfig { + config := CLIConfig{ + Enabled: c.Bool(EnabledFlagName), + PollInterval: c.Duration(PollIntervalFlagName), + L1URL: c.String(L1UrlFlagName), + RollupL1URL: c.String(RollupL1UrlFlagName), + Namespace: c.Uint64(NamespaceFlagName), + CaffeinationHeightEspresso: c.Uint64(CaffeinationHeightEspresso), + CaffeinationHeightL2: c.Uint64(CaffeinationHeightL2), + EspressoAttestationService: c.String(AttestationServiceFlagName), + VerifyReceiptMaxBlocks: c.Uint64(VerifyReceiptMaxBlocksFlagName), + VerifyReceiptSafetyTimeout: c.Duration(VerifyReceiptSafetyTimeoutFlagName), + VerifyReceiptRetryDelay: c.Duration(VerifyReceiptRetryDelayFlagName), + } + + config.QueryServiceURLs = c.StringSlice(QueryServiceUrlsFlagName) + + addrStr := c.String(LightClientAddrFlagName) + config.LightClientAddr = common.HexToAddress(addrStr) + + batchAuthenticatorAddrStr := c.String(BatchAuthenticatorAddrFlagName) + config.BatchAuthenticatorAddr = common.HexToAddress(batchAuthenticatorAddrStr) + + pkStr := c.String(TestingBatcherPrivateKeyFlagName) + pkStr = strings.TrimPrefix(pkStr, "0x") + pk, err := crypto.HexToECDSA(pkStr) + if err == nil { + config.TestingBatcherPrivateKey = pk + } + + return config +} + +func BatchStreamerFromCLIConfig[B op.Batch]( + cfg CLIConfig, + log log.Logger, + unmarshalBatch func([]byte) (*B, error), +) (*op.BatchStreamer[B], error) { + if !cfg.Enabled { + return nil, fmt.Errorf("espresso is not enabled") + } + + l1Client, err := ethclient.Dial(cfg.L1URL) + if err != nil { + return nil, fmt.Errorf("failed to dial L1 RPC at %s: %w", cfg.L1URL, err) + } + + RollupL1Client, err := ethclient.Dial(cfg.RollupL1URL) + if err != nil { + return nil, fmt.Errorf("failed to dial Rollup L1 RPC at %s: %w", cfg.RollupL1URL, err) + } + + urlZero := cfg.QueryServiceURLs[0] + espressoClient := espressoClient.NewClient(urlZero) + + espressoLightClient, err := espressoLightClient.NewLightclientCaller(cfg.LightClientAddr, l1Client) + if err != nil { + return nil, fmt.Errorf("failed to create Espresso light client") + } + + return op.NewEspressoStreamer( + cfg.Namespace, + NewAdaptL1BlockRefClient(l1Client), + NewAdaptL1BlockRefClient(RollupL1Client), + espressoClient, + espressoLightClient, + log, + unmarshalBatch, + cfg.CaffeinationHeightEspresso, + cfg.CaffeinationHeightL2, + cfg.BatchAuthenticatorAddr, + false, + ) +} diff --git a/espresso/ethclient.go b/espresso/ethclient.go new file mode 100644 index 00000000000..38328ce88f8 --- /dev/null +++ b/espresso/ethclient.go @@ -0,0 +1,60 @@ +package espresso + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/ethereum-optimism/optimism/espresso/bindings" +) + +// AdaptL1BlockRefClient is a wrapper around eth.L1BlockRef that implements the espresso.L1Client interface +type AdaptL1BlockRefClient struct { + L1Client *ethclient.Client +} + +// NewAdaptL1BlockRefClient creates a new L1BlockRefClient +func NewAdaptL1BlockRefClient(L1Client *ethclient.Client) *AdaptL1BlockRefClient { + return &AdaptL1BlockRefClient{ + L1Client: L1Client, + } +} + +// HeaderHashByNumber implements the espresso.L1Client interface +func (c *AdaptL1BlockRefClient) HeaderHashByNumber(ctx context.Context, number *big.Int) (common.Hash, error) { + expectedL1BlockRef, err := c.L1Client.HeaderByNumber(ctx, number) + if err != nil { + return common.Hash{}, err + } + + return expectedL1BlockRef.Hash(), nil +} + +func (c *AdaptL1BlockRefClient) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + return c.L1Client.CodeAt(ctx, contract, blockNumber) +} + +func (c *AdaptL1BlockRefClient) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + return c.L1Client.CallContract(ctx, call, blockNumber) +} + +// FetchEspressoBatcherAddress reads the Espresso batcher address from the BatchAuthenticator +// contract on L1. This is used by the caff node to determine which address signed +// Espresso batches, since the Espresso batcher may use a different key than the +// SystemConfig batcher (fallback batcher). +func FetchEspressoBatcherAddress(ctx context.Context, l1Client *ethclient.Client, batchAuthenticatorAddr common.Address) (common.Address, error) { + caller, err := bindings.NewBatchAuthenticatorCaller(batchAuthenticatorAddr, l1Client) + if err != nil { + return common.Address{}, fmt.Errorf("failed to bind BatchAuthenticator at %s: %w", batchAuthenticatorAddr, err) + } + addr, err := caller.EspressoBatcher(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, fmt.Errorf("failed to call BatchAuthenticator.espressoBatcher(): %w", err) + } + return addr, nil +} diff --git a/espresso/interface.go b/espresso/interface.go new file mode 100644 index 00000000000..8445baf08c6 --- /dev/null +++ b/espresso/interface.go @@ -0,0 +1,77 @@ +package espresso + +import ( + "context" + + op "github.com/EspressoSystems/espresso-streamers/op" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" +) + +// EspressoStreamer defines the interface for the Espresso streamer. +type EspressoStreamer[B op.Batch] interface { + // Update will update the `EspressoStreamer“ by attempting to ensure that + // the next call to the `Next` method will return a `Batch`. + // + // It attempts to ensure the existence of a next batch, provided no errors + // occur when communicating with HotShot, by processing Blocks retrieved + // from `HotShot` in discreet batches. If each processing of a batch of + // blocks will not yield a new `Batch`, then it will continue to process + // the next batch of blocks from HotShot until it runs out of blocks to + // process. + // + // NOTE: this method is best effort. It is unable to guarantee that the + // next call to `Next` will return a batch. However, the only things + // that will prevent the next call to `Next` from returning a batch is if + // there are no more HotShot blocks to process currently, or if an error + // occurs when communicating with HotShot. + Update(ctx context.Context) error + + // Refresh updates the local references of the EspressoStreamer to the + // specified values. + // + // These values can be used to help determine whether the Streamer needs + // to be reset or not. + // + // NOTE: This will only automatically reset the Streamer if the + // `safeBatchNumber` moves backwards. + Refresh(ctx context.Context, finalizedL1 eth.L1BlockRef, safeBatchNumber uint64, safeL1Origin eth.BlockID) error + + // RefreshSafeL1Origin updates the safe L1 origin for the streamer. This is + // used to help the streamer determine if it needs to be reset or not based + // on the safe L1 origin moving backwards. + // + // NOTE: This will only automatically reset the Streamer if the + // `safeL1Origin` moves backwards. + RefreshSafeL1Origin(safeL1Origin eth.BlockID) + + // Reset will reset the Streamer to the last known good safe state. + // This generally means resetting to the last know good safe batch + // position, but in the case of consuming blocks from Espresso, it will + // also reset the starting Espresso block position to the last known + // good safe block position there as well. + Reset() + + // UnmarshalBatch is a convenience method that allows the caller to + // attempt to unmarshal a batch from the provided byte slice. + UnmarshalBatch(b []byte) (*B, error) + + // HasNext checks to see if there are any batches left to read in the + // streamer. + HasNext(ctx context.Context) bool + + // Next attempts to return the next batch from the streamer. If there + // are no batches left to read, at the moment of the call, it will return + // nil. + Next(ctx context.Context) *B + + // Peek attempts to return the next batch from the streamer without advancing the streamer's position. + // If there are no batches left to read, at the moment of the call, it will return nil. + Peek(ctx context.Context) *B + + // SetProperHead drains stale/wrong-fork entries from the buffer front, + // positioning it at the correct fork for the next Peek call. Should be called + // when Peek returns a batch whose parentHash doesn't match the current chain tip. + // No-ops if headBatch's block number doesn't match the expected next batch position. + SetProperHead(parentHash common.Hash) +} diff --git a/go.mod b/go.mod index a4e20093b5a..7cf03a16890 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.13 require ( github.com/BurntSushi/toml v1.5.0 github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513 + github.com/EspressoSystems/espresso-streamers v1.1.0 github.com/Masterminds/semver/v3 v3.3.1 github.com/andybalholm/brotli v1.1.0 github.com/base/go-bip39 v1.1.0 @@ -22,7 +23,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 github.com/ethereum-optimism/go-ethereum-hdwallet v0.1.4-0.20251001155152-4eb15ccedf7e github.com/ethereum-optimism/superchain-registry/validation v0.0.0-20260115192958-fb86a23cd30e - github.com/ethereum/go-ethereum v1.17.0 + github.com/ethereum/go-ethereum v1.17.1 github.com/fsnotify/fsnotify v1.9.0 github.com/golang/snappy v1.0.0 github.com/google/go-cmp v0.7.0 @@ -117,7 +118,7 @@ require ( github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect github.com/containerd/cgroups v1.1.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect github.com/dchest/siphash v1.2.3 // indirect diff --git a/go.sum b/go.sum index 23020ded907..254233ea5fc 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/DataDog/zstd v1.5.6-0.20230824185856-869dae002e5e h1:ZIWapoIRN1VqT8GR github.com/DataDog/zstd v1.5.6-0.20230824185856-869dae002e5e/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513 h1:4KqlmnDg8pPPj1hCtiLnN0Fh9ltp+PPKOft9I06zQOA= github.com/EspressoSystems/espresso-network/sdks/go v0.3.5-0.20260410134522-1a819609a513/go.mod h1:kaxR08mJb5Mijy7a2RhWCIWOevFI4PcXwDkzoEbsVTk= +github.com/EspressoSystems/espresso-streamers v1.1.0 h1:+35Y9I3BCMyVIyFX6DNO6tq8ZKeuyOAve4DrgIhaY2s= +github.com/EspressoSystems/espresso-streamers v1.1.0/go.mod h1:eDGUEvlyxNey9gdpQhKaswAOp5ZWHPVbhNtWfHkpJvU= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -173,8 +175,8 @@ github.com/coreos/pkg v0.0.0-20220810130054-c7d1c02cb6cf h1:GOPo6vn/vTN+3IwZBvXX github.com/coreos/pkg v0.0.0-20220810130054-c7d1c02cb6cf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= From 07013560bc13e7003e50c826f6b0ed1814520771 Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Tue, 26 May 2026 18:18:48 +0200 Subject: [PATCH 04/13] op-batcher: add Nitro NSM attestation helper Bring in op-batcher/enclave/attestation.go: a thin wrapper around the hf/nsm library that obtains an AWS Nitro NSM attestation document over a given public key. Used by the Espresso batcher (next commit) to attach a TEE attestation to its registration with the BatchAuthenticator. Adds the github.com/hf/nsm dependency. Builds on all platforms; NSM device access is only attempted at runtime when invoked from inside a Nitro enclave. Co-authored-by: OpenCode --- go.mod | 3 ++ go.sum | 8 ++++++ op-batcher/enclave/attestation.go | 47 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 op-batcher/enclave/attestation.go diff --git a/go.mod b/go.mod index 7cf03a16890..7729b48b7f1 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/hashicorp/raft v1.7.3 github.com/hashicorp/raft-boltdb/v2 v2.3.1 github.com/hashicorp/raft-wal v0.4.2 + github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 github.com/holiman/uint256 v1.3.2 github.com/ipfs/go-datastore v0.6.0 github.com/ipfs/go-ds-leveldb v0.5.0 @@ -82,10 +83,12 @@ require ( github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect github.com/coreos/pkg v0.0.0-20220810130054-c7d1c02cb6cf // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fxamacker/cbor/v2 v2.2.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect ) diff --git a/go.sum b/go.sum index 254233ea5fc..c0e7d171b9d 100644 --- a/go.sum +++ b/go.sum @@ -260,6 +260,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= +github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 h1:f6D9Hr8xV8uYKlyuj8XIruxlh9WjVjdh1gIicAS7ays= github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= @@ -423,6 +425,8 @@ github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyv github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE= github.com/hashicorp/raft-wal v0.4.2 h1:DV1jgqEumNfdNpOaZ9mL1Gu7Mz59epFtiE6CoqnHrlY= github.com/hashicorp/raft-wal v0.4.2/go.mod h1:S92ainH+6fRuWk6BtZKJ8EgcGgNTKx48Hk5dhOOY1DM= +github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 h1:pU32bJGmZwF4WXb9Yaz0T8vHDtIPVxqDOdmYdwTQPqw= +github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9/go.mod h1:MJsac5D0fKcNWfriUERtln6segcGfD6Nu0V5uGBbPf8= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= @@ -887,6 +891,8 @@ github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMI github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.4 h1:0de1OFQxnNqAu+x2FAKKCVIrnfGKQbs7FQz++tB0+Uw= github.com/wlynxg/anet v0.0.4/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -962,6 +968,7 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -1133,6 +1140,7 @@ golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= diff --git a/op-batcher/enclave/attestation.go b/op-batcher/enclave/attestation.go new file mode 100644 index 00000000000..26c4adf7205 --- /dev/null +++ b/op-batcher/enclave/attestation.go @@ -0,0 +1,47 @@ +package enclave + +import ( + "crypto/ecdsa" + "errors" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/hf/nsm" + "github.com/hf/nsm/request" +) + +func attest(nonce, userData, publicKey []byte) ([]byte, error) { + sess, err := nsm.OpenDefaultSession() + if err != nil { + return nil, err + } + defer sess.Close() + + res, err := sess.Send(&request.Attestation{ + Nonce: nonce, + UserData: userData, + PublicKey: publicKey, + }) + if err != nil { + return nil, err + } + + if res.Error != "" { + return nil, errors.New(string(res.Error)) + } + + if res.Attestation == nil || res.Attestation.Document == nil { + return nil, errors.New("NSM device did not return an attestation") + } + + return res.Attestation.Document, nil +} + +func AttestationWithPublicKey(publicKey *ecdsa.PublicKey) ([]byte, error) { + // Use empty slices for nonce and publicKey when they're not needed + nonce := make([]byte, 0) + txData := make([]byte, 0) + publicKeyBytes := crypto.FromECDSAPub(publicKey) + + // Call the existing attest function with txData as userData + return attest(nonce, txData, publicKeyBytes) +} From 103003bed01085b3916b975570c0b3cb15339829 Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Wed, 27 May 2026 16:06:36 +0200 Subject: [PATCH 05/13] op-batcher: integrate TEE batcher and Espresso submission loop Add the Espresso TEE batcher write-path on top of the fallback batcher: - op-batcher/batcher/espresso.go: Espresso submission loop (peeks the channel manager, converts each L2 block to an EspressoBatch, submits it to Espresso, waits for inclusion, and then posts the batch txs to L1 with TEE-attested BatchAuthenticator.authenticateBatchInfo calls). - op-batcher/batcher/espresso_service.go: EspressoBatcherConfig, initEspresso (Espresso client / light-client construction, optional TEE attestation gathering), and the initChainSigner hook that wraps the txmgr into a opcrypto.ChainSigner. - op-batcher/batcher/espresso_helpers_test.go and espresso_transaction_submitter_test.go: unit tests for the helpers and the TEE transaction submitter. Extends the existing fallback wiring: - op-batcher/batcher/espresso_driver.go: adds EspressoDriverSetup fields (Client/LightClient/ChainSigner/SequencerAddress/Attestation), batcherL1Adapter, setupEspressoStreamer, startEspressoLoops, resetEspressoStreamer; extends dispatchAuthenticatedSendTx with the TEE branch (always authenticates when Espresso.Enabled). - op-batcher/batcher/espresso_active.go: adds isBatcherActive (queries BatchAuthenticator.activeIsEspresso to gate publishing against this batcher's role). - op-batcher/batcher/driver.go: extends DriverSetup with the Espresso EspressoDriverSetup field; adds espressoSubmitter / espressoStreamer / teeVerifierAddress / degradedLog fields on BatchSubmitter; calls setupEspressoStreamer in NewBatchSubmitter; branches StartBatchSubmitting on Espresso.Enabled to call startEspressoLoops; calls resetEspressoStreamer in clearState. - op-batcher/batcher/service.go: BatcherConfig.Espresso field; EspressoClient / EspressoLightClient / ChainSigner / Attestation runtime fields; initEspresso / initChainSigner / applyEspressoDriverSetup call-outs. - op-batcher/batcher/config.go: thread Espresso espresso.CLIConfig through CLIConfig. - op-batcher/flags/flags.go: register espresso.CLIFlags (TEE-only flags; the --espresso.fallback-auth-lead-time flag added by the fallback PR continues to live in op-batcher/flags/flags.go). Also adds op-service/log/repeat_state.go (RepeatStateLogger) and its test, used by the Espresso submission loop's tick-driven warnings. A safeTestRecorder helper is inlined into the test to avoid pulling in the unrelated debouncer. Adds the github.com/hf/nitrite dependency (transitively required by hf/nsm for attestation document parsing). Co-authored-by: OpenCode --- go.mod | 1 + go.sum | 2 + op-batcher/batcher/config.go | 6 + op-batcher/batcher/driver.go | 38 +- op-batcher/batcher/driver_test.go | 5 +- op-batcher/batcher/espresso.go | 1485 +++++++++++++++++ op-batcher/batcher/espresso_active.go | 54 + op-batcher/batcher/espresso_driver.go | 159 +- op-batcher/batcher/espresso_helpers_test.go | 263 +++ op-batcher/batcher/espresso_service.go | 191 +++ .../espresso_transaction_submitter_test.go | 177 ++ op-batcher/batcher/service.go | 23 + op-batcher/flags/flags.go | 2 + op-service/log/repeat_state.go | 91 + op-service/log/repeat_state_test.go | 234 +++ 15 files changed, 2717 insertions(+), 14 deletions(-) create mode 100644 op-batcher/batcher/espresso.go create mode 100644 op-batcher/batcher/espresso_helpers_test.go create mode 100644 op-batcher/batcher/espresso_service.go create mode 100644 op-batcher/batcher/espresso_transaction_submitter_test.go create mode 100644 op-service/log/repeat_state.go create mode 100644 op-service/log/repeat_state_test.go diff --git a/go.mod b/go.mod index 7729b48b7f1..a98c2518a8d 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/hashicorp/raft v1.7.3 github.com/hashicorp/raft-boltdb/v2 v2.3.1 github.com/hashicorp/raft-wal v0.4.2 + github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 github.com/holiman/uint256 v1.3.2 github.com/ipfs/go-datastore v0.6.0 diff --git a/go.sum b/go.sum index c0e7d171b9d..6e5dca219ba 100644 --- a/go.sum +++ b/go.sum @@ -425,6 +425,8 @@ github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyv github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE= github.com/hashicorp/raft-wal v0.4.2 h1:DV1jgqEumNfdNpOaZ9mL1Gu7Mz59epFtiE6CoqnHrlY= github.com/hashicorp/raft-wal v0.4.2/go.mod h1:S92ainH+6fRuWk6BtZKJ8EgcGgNTKx48Hk5dhOOY1DM= +github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 h1:XBSq4rXFUgD8ic6Mr7dBwJN/47yg87XpZQhiknfr4Cg= +github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303/go.mod h1:ycRhVmo6wegyEl6WN+zXOHUTJvB0J2tiuH88q/McTK8= github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 h1:pU32bJGmZwF4WXb9Yaz0T8vHDtIPVxqDOdmYdwTQPqw= github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9/go.mod h1:MJsac5D0fKcNWfriUERtln6segcGfD6Nu0V5uGBbPf8= github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= diff --git a/op-batcher/batcher/config.go b/op-batcher/batcher/config.go index c5e608d7038..74ff5f6a715 100644 --- a/op-batcher/batcher/config.go +++ b/op-batcher/batcher/config.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/params" "github.com/urfave/cli/v2" + "github.com/ethereum-optimism/optimism/espresso" altda "github.com/ethereum-optimism/optimism/op-alt-da" "github.com/ethereum-optimism/optimism/op-batcher/compressor" "github.com/ethereum-optimism/optimism/op-batcher/config" @@ -157,6 +158,10 @@ type CLIConfig struct { // authentication gate. See BatcherConfig.FallbackAuthLeadTime in // service.go and isFallbackAuthRequired in espresso_active.go. FallbackAuthLeadTime time.Duration + + // Espresso groups all TEE-batcher-specific CLI flags. See + // espresso/cli.go for the flag definitions. + Espresso espresso.CLIConfig } func (c *CLIConfig) Check() error { @@ -257,6 +262,7 @@ func NewConfig(ctx *cli.Context) *CLIConfig { RPC: oprpc.ReadCLIConfig(ctx), AltDA: altda.ReadCLIConfig(ctx), FallbackAuthLeadTime: ctx.Duration(flags.FallbackAuthLeadTimeFlag.Name), + Espresso: espresso.ReadCLIConfig(ctx), ThrottleConfig: ThrottleConfig{ AdditionalEndpoints: ctx.StringSlice(flags.AdditionalThrottlingEndpointsFlag.Name), TxSizeLowerLimit: ctx.Uint64(flags.ThrottleTxSizeLowerLimitFlag.Name), diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 1b0f4aa73b9..bca2442e710 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -12,6 +12,7 @@ import ( "golang.org/x/sync/errgroup" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" @@ -20,6 +21,7 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum-optimism/optimism/espresso" altda "github.com/ethereum-optimism/optimism/op-alt-da" "github.com/ethereum-optimism/optimism/op-batcher/batcher/throttler" config "github.com/ethereum-optimism/optimism/op-batcher/config" @@ -28,6 +30,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/derive" "github.com/ethereum-optimism/optimism/op-service/dial" "github.com/ethereum-optimism/optimism/op-service/eth" + oplog "github.com/ethereum-optimism/optimism/op-service/log" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) @@ -85,6 +88,7 @@ func (r txRef) string(txIDStringer func(txID) string) string { type L1Client interface { HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) + bind.ContractBackend } type L2Client interface { @@ -112,6 +116,11 @@ type DriverSetup struct { ChannelConfig ChannelConfigProvider AltDA AltDAClient ChannelOutFactory ChannelOutFactory + + // Espresso groups all TEE-batcher-specific runtime state plumbed from + // BatcherService. Defined in espresso_driver.go to keep the upstream + // Optimism field block compact. + Espresso EspressoDriverSetup } // BatchSubmitter encapsulates a service responsible for submitting L2 tx @@ -137,7 +146,7 @@ type BatchSubmitter struct { publishSignal chan pubInfo - // authGroup tracks the fallback batcher's receipt-watcher goroutines (one + // authGroup tracks the fallback and TEE batchers' auth goroutines (one // per auth+batch pair) so the publishing loop can drain them via // waitForAuthGroup before closing receiptsCh. New watchers are back-pressured // (not hard-bounded) by the txmgr Queue: queue.Send blocks at @@ -145,6 +154,15 @@ type BatchSubmitter struct { // though a slow receipts loop can briefly leave more than that parked on their // final receiptsCh send. authGroup sync.WaitGroup + + espressoSubmitter *espressoTransactionSubmitter + espressoStreamer espresso.EspressoStreamer[derive.EspressoBatch] + + teeVerifierAddress common.Address + + // degradedLog throttles repeated warnings from tick-driven loops so the + // log debouncer doesn't see the same message every poll interval. + degradedLog *oplog.RepeatStateLogger } // NewBatchSubmitter initializes the BatchSubmitter driver from a preconfigured DriverSetup @@ -157,6 +175,7 @@ func NewBatchSubmitter(setup DriverSetup) *BatchSubmitter { batcher := &BatchSubmitter{ DriverSetup: setup, channelMgr: state, + degradedLog: oplog.NewRepeatStateLogger(), } err := batcher.SetThrottleController(setup.Config.ThrottleParams.ControllerType, setup.Config.ThrottleParams.PIDConfig) @@ -164,6 +183,8 @@ func NewBatchSubmitter(setup DriverSetup) *BatchSubmitter { panic(err) } + batcher.setupEspressoStreamer() + return batcher } @@ -211,10 +232,16 @@ func (l *BatchSubmitter) StartBatchSubmitting() error { l.Log.Warn("Throttling loop is DISABLED due to 0 throttle-threshold. This should not be disabled in prod.") } - l.wg.Add(3) - go l.receiptsLoop(l.wg, receiptsCh) // ranges over receiptsCh channel - go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done. - go l.blockLoadingLoop(l.shutdownCtx, l.wg, unsafeBytesUpdated, publishSignal) // sends on unsafeBytesUpdated (if throttling enabled), and publishSignal. Closes them both when done + if l.Config.Espresso.Enabled { + if err := l.startEspressoLoops(receiptsCh, publishSignal); err != nil { + return err + } + } else { + l.wg.Add(3) + go l.receiptsLoop(l.wg, receiptsCh) // ranges over receiptsCh channel + go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done. + go l.blockLoadingLoop(l.shutdownCtx, l.wg, unsafeBytesUpdated, publishSignal) // sends on unsafeBytesUpdated (if throttling enabled), and publishSignal. Closes them both when done + } l.Log.Info("Batch Submitter started") return nil @@ -856,6 +883,7 @@ func (l *BatchSubmitter) clearState(ctx context.Context) { l.channelMgrMutex.Lock() defer l.channelMgrMutex.Unlock() l.channelMgr.Clear(l1SafeOrigin) + l.resetEspressoStreamer() return true } } diff --git a/op-batcher/batcher/driver_test.go b/op-batcher/batcher/driver_test.go index e5eaf518453..cbf0b77361d 100644 --- a/op-batcher/batcher/driver_test.go +++ b/op-batcher/batcher/driver_test.go @@ -26,6 +26,7 @@ import ( "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/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" @@ -480,7 +481,9 @@ func TestBatchSubmitter_CriticalError(t *testing.T) { // ======= ALTDA TESTS ======= // fakeL1Client is just a dummy struct. All fault injection is done via the fakeTxMgr (which doesn't interact with this fakeL1Client). -type fakeL1Client struct{} +type fakeL1Client struct { + bind.ContractBackend +} func (f *fakeL1Client) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { if number == nil { diff --git a/op-batcher/batcher/espresso.go b/op-batcher/batcher/espresso.go new file mode 100644 index 00000000000..5825626bf11 --- /dev/null +++ b/op-batcher/batcher/espresso.go @@ -0,0 +1,1485 @@ +package batcher + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + tagged_base64 "github.com/EspressoSystems/espresso-network/sdks/go/tagged-base64" + espressoCommon "github.com/EspressoSystems/espresso-network/sdks/go/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + + "github.com/ethereum-optimism/optimism/espresso" + "github.com/ethereum-optimism/optimism/espresso/bindings" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txmgr" +) + +// EspressoOnchainProof is the proof structure returned by the attestation service for onchain verification. +type EspressoOnchainProof struct { + Proof json.RawMessage `json:"proof,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + RawProof struct { + Journal string `json:"journal"` + } `json:"raw_proof"` + OnchainProof string `json:"onchain_proof"` +} + +// espressoSubmitTransactionJob is a struct that holds the state required to +// submit a transaction to Espresso. +// It contains the transaction to be submitted itself, and a number to +// track the total number of attempts to submit this transaction to Espresso. +type espressoSubmitTransactionJob struct { + attempts int + transaction *espressoCommon.Transaction +} + +// espressoSubmitTransactionJobResponse is a struct that holds the +// response from the Espresso client after submitting a transaction. +// It contains the job that was submitted, the hash of the transaction +// that was submitted (if successful), and any error that occurred during the +// submission (if unsuccessful). +type espressoSubmitTransactionJobResponse struct { + job espressoSubmitTransactionJob + hash *espressoCommon.TaggedBase64 + err error +} + +// espressoTransactionJobAttempt is a struct that holds the job and +// response channel for a transaction submission job. +// +// This is the unit of work that is submitted to the worker to process +// for transaction submissions. +type espressoTransactionJobAttempt struct { + job espressoSubmitTransactionJob + resp chan espressoSubmitTransactionJobResponse +} + +// espressoVerifyReceiptJob is a struct that holds the state required to +// verify a receipt for a transaction that was submitted to Espresso. +// It contains the transaction that was submitted, the hash of the +// transaction, and the number of attempts to verify the receipt. +type espressoVerifyReceiptJob struct { + attempts int + startHeight uint64 // HotShot block height when verification began (set on first attempt) + startTime time.Time // wall-clock time when verification began (safety backstop) + transaction espressoSubmitTransactionJob + hash *espressoCommon.TaggedBase64 +} + +// espressoVerifyReceiptJobResponse is a struct that holds the +// response from the Espresso client after verifying a receipt. +// It contains the job that was submitted, and any error that occurred +// during the verification (if unsuccessful). +type espressoVerifyReceiptJobResponse struct { + job espressoVerifyReceiptJob + err error + currentHeight uint64 // latest known HotShot block height at time of verification attempt +} + +// espressoVerifyReceiptJobAttempt is a struct that holds the job and +// response channel for a receipt verification job. +// +// This is the unit of work that is submitted to the worker to process +// for receipt verifications. +type espressoVerifyReceiptJobAttempt struct { + job espressoVerifyReceiptJob + resp chan espressoVerifyReceiptJobResponse +} + +// espressoTransactionSubmitter is a struct that holds the state that governs +// the worker queue processing details for submitting transactions to Espresso +// without spawning arbitrarily many goroutines. +type espressoTransactionSubmitter struct { + ctx context.Context + wg *sync.WaitGroup + submitJobQueue chan espressoSubmitTransactionJob + submitRespQueue chan espressoSubmitTransactionJobResponse + submitWorkerQueue chan chan espressoTransactionJobAttempt + verifyReceiptJobQueue chan espressoVerifyReceiptJob + verifyReceiptRespQueue chan espressoVerifyReceiptJobResponse + verifyReceiptWorkerQueue chan chan espressoVerifyReceiptJobAttempt + espresso espressoClient.EspressoClient + latestBlockHeight atomic.Uint64 // shared HotShot block height, updated by trackBlockHeight + verifyReceiptMaxBlocks uint64 + verifyReceiptSafetyTimeout time.Duration + verifyReceiptRetryDelay time.Duration + numInFlightJobs atomic.Int64 + numMaxInFlightJobs int +} + +// EspressoTransactionSubmitterConfig is a configuration struct for the +// EspressoTransactionSubmitter. It contains the configurable details for +// creating the EspressoTransactionSubmitter. +type EspressoTransactionSubmitterConfig struct { + Ctx context.Context + EspressoClient espressoClient.EspressoClient + Wg *sync.WaitGroup + SubmitJobQueueCapacity int + SubmitResponseQueueCapacity int + VerifyReceiptJobQueueCapacity int + VerifyReceiptResponseQueueCapacity int + VerifyReceiptMaxBlocks uint64 + VerifyReceiptSafetyTimeout time.Duration + VerifyReceiptRetryDelay time.Duration + MaxInFlightJobs int +} + +// EspressoTransactionSubmitterOption is a function that can be used to +// configure the EspressoTransactionSubmitterConfig. +type EspressoTransactionSubmitterOption func(*EspressoTransactionSubmitterConfig) + +// WithContext is an option that can be used to set the Espresso client +// for the EspressoTransactionSubmitterConfig. +func WithContext(ctx context.Context) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.Ctx = ctx + } +} + +// WithEspressoClient is an option that can be used to set the Espresso client +// for the EspressoTransactionSubmitterConfig. +func WithEspressoClient(client espressoClient.EspressoClient) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.EspressoClient = client + } +} + +// WithWaitGroup is an option that can be used to set the wait group +// for the EspressoTransactionSubmitterConfig. +func WithWaitGroup(wg *sync.WaitGroup) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.Wg = wg + } +} + +// WithVerifyReceiptMaxBlocks sets the number of HotShot blocks to wait for a +// submitted transaction to become queryable before re-submitting. +func WithVerifyReceiptMaxBlocks(n uint64) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.VerifyReceiptMaxBlocks = n + } +} + +// WithVerifyReceiptSafetyTimeout sets the wall-clock backstop for receipt +// verification. If the block height tracker is stale or broken, re-submission +// is triggered after this duration. +func WithVerifyReceiptSafetyTimeout(d time.Duration) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.VerifyReceiptSafetyTimeout = d + } +} + +// WithVerifyReceiptRetryDelay sets the delay between receipt verification retries. +func WithVerifyReceiptRetryDelay(d time.Duration) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.VerifyReceiptRetryDelay = d + } +} + +// WithMaxInFlightJobs sets the maximum number of inflight requests to +// have at once. Once at capacity all new submission attempts will +// automatically fail. +func WithMaxInFlightJobs(n int) EspressoTransactionSubmitterOption { + return func(config *EspressoTransactionSubmitterConfig) { + config.MaxInFlightJobs = n + } +} + +// NewEspressoTransactionSubmitter creates a new EspressoTransactionSubmitter +// throttle with the given context and espresso client. It will create a new +// transaction submitter with some default options, and apply those options to +// the configuration. +// +// The resulting instance should reflect the given configuration. +// After returning, the caller should call SpawnWorkers to start the workers, +// and Start to start the job scheduling and response handling portions of the +// transaction submitter. After that, the user should be able to submit +// transactions to the submitter via the SubmitTransaction method. +func NewEspressoTransactionSubmitter(options ...EspressoTransactionSubmitterOption) *espressoTransactionSubmitter { + config := EspressoTransactionSubmitterConfig{ + Ctx: context.Background(), + Wg: new(sync.WaitGroup), + SubmitJobQueueCapacity: espresso.DefaultMaxInFlightRequestsToEspresso, + SubmitResponseQueueCapacity: 10, + VerifyReceiptJobQueueCapacity: espresso.DefaultMaxInFlightRequestsToEspresso, + VerifyReceiptResponseQueueCapacity: 10, + VerifyReceiptMaxBlocks: espresso.DefaultVerifyReceiptMaxBlocks, + VerifyReceiptSafetyTimeout: espresso.DefaultVerifyReceiptSafetyTimeout, + VerifyReceiptRetryDelay: espresso.DefaultVerifyReceiptRetryDelay, + MaxInFlightJobs: espresso.DefaultMaxInFlightRequestsToEspresso, + } + + for _, option := range options { + option(&config) + } + + if config.EspressoClient == nil { + panic("Espresso client is required") + } + + return &espressoTransactionSubmitter{ + ctx: config.Ctx, + wg: config.Wg, + submitJobQueue: make(chan espressoSubmitTransactionJob, config.SubmitJobQueueCapacity), + submitRespQueue: make(chan espressoSubmitTransactionJobResponse, config.SubmitResponseQueueCapacity), + submitWorkerQueue: make(chan chan espressoTransactionJobAttempt), + verifyReceiptJobQueue: make(chan espressoVerifyReceiptJob, config.VerifyReceiptJobQueueCapacity), + verifyReceiptRespQueue: make(chan espressoVerifyReceiptJobResponse, config.VerifyReceiptResponseQueueCapacity), + verifyReceiptWorkerQueue: make(chan chan espressoVerifyReceiptJobAttempt), + espresso: config.EspressoClient, + verifyReceiptMaxBlocks: config.VerifyReceiptMaxBlocks, + verifyReceiptSafetyTimeout: config.VerifyReceiptSafetyTimeout, + verifyReceiptRetryDelay: config.VerifyReceiptRetryDelay, + numInFlightJobs: atomic.Int64{}, + numMaxInFlightJobs: config.MaxInFlightJobs, + } +} + +// ErrTooManyInFlightRequests is an error that is returned when there are +// too many requests in flight at once right now. +type ErrTooManyInFlightRequests struct { + NumInFlightRequests int + MaxInFlightRequests int +} + +// Error implements error +func (e ErrTooManyInFlightRequests) Error() string { + return fmt.Sprintf("too many requests in flight to espresso, in flight requests: %d, maximum allowed: %d", e.NumInFlightRequests, e.MaxInFlightRequests) +} + +// ErrSubmitToEspressoChannelFull is an error that is returned when the channel +// to submit a transaction to the espresso job channel is full. +// +// This ultimately means that the channel buffer size of the job channel is +// smaller than the max number of in flight requests allowed. +type ErrSubmitToEspressoChannelFull struct { + Capacity int + Len int +} + +// Error implements error +func (e ErrSubmitToEspressoChannelFull) Error() string { + return fmt.Sprintf("submit transaction to espresso job channel is full, len: %d, capacity: %d", e.Len, e.Capacity) +} + +// SubmitTransaction will submit a transaction to the Job queue. +// +// NOTE: There is a limit on the maximum number of inflight requests we allow +// at once. If we're over or at capacity, we'll return an error indicating so. +// If we have capacity available, we'll attempt to submit to the channel, and +// if we're unable to, we'll return an error. This will **NOT** block. +func (s *espressoTransactionSubmitter) SubmitTransaction(job *espressoCommon.Transaction) error { + // Check to see if we're over capacity, and if we are, then immediately + // return an error. + if numInFlightRequests, numMaxInFlightRequests := s.numInFlightJobs.Load(), int64(s.numMaxInFlightJobs); numInFlightRequests >= numMaxInFlightRequests { + return ErrTooManyInFlightRequests{ + NumInFlightRequests: int(numInFlightRequests), + MaxInFlightRequests: int(numMaxInFlightRequests), + } + } + + // Construct the job submission + jobSubmission := espressoSubmitTransactionJob{ + transaction: job, + } + + select { + default: + return ErrSubmitToEspressoChannelFull{ + Len: len(s.submitJobQueue), + Capacity: cap(s.submitJobQueue), + } + case s.submitJobQueue <- jobSubmission: + // increment in flight requests + s.numInFlightJobs.Add(1) + return nil + } +} + +// Evaluation result for a job. +type JobEvaluation int + +const ( + // Continue handling the current job. + Handle JobEvaluation = iota + // Retry the submission. + RetrySubmission + // Retry the verification. + RetryVerification + // Skip the current job and proceed to the next one. + Skip +) + +// Evaluate the submission job. +// +// # Returns +// +// * If there is no error: Handle. +// +// * If there is a permanent issue that won't be fixed by a retry: Skip. +// +// * Otherwise: RetrySubmission. +func evaluateSubmission(jobResp espressoSubmitTransactionJobResponse) JobEvaluation { + err := jobResp.err + + // If there's no error, continue handling the submission. + if err == nil { + return Handle + } + + if errors.Is(err, espressoClient.ErrPermanent) { + return Skip + } + + if !errors.Is(err, espressoClient.ErrEphemeral) { + // Log the warning for a potentially missed error handling, but still retry it. + log.Warn("error not explicitly marked as retryable or not", "err", err) + } + + // Otherwise, retry the submission. + return RetrySubmission +} + +// handleTransactionSubmitJobResponse is a function that is meant to be run in a +// goroutine. +// +// It handles the responses from the submit transaction jobs. It will +// determine if the transaction was successfully submitted to Espresso, and +// if not, it will retry the transaction. If the transaction was successfully +// submitted, it will then submit a job to the verify receipt job queue to +// verify the receipt of the transaction. +func (s *espressoTransactionSubmitter) handleTransactionSubmitJobResponse() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for { + var jobResp espressoSubmitTransactionJobResponse + var ok bool + + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + log.Debug("Espresso transaction submitter queue status", + "submitJobQueue", len(s.submitJobQueue), + "submitRespQueue", len(s.submitRespQueue), + "verifyReceiptJobQueue", len(s.verifyReceiptJobQueue), + "verifyReceiptRespQueue", len(s.verifyReceiptRespQueue)) + continue + case jobResp, ok = <-s.submitRespQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + switch evaluation := evaluateSubmission(jobResp); evaluation { + case Skip: + s.numInFlightJobs.Add(-1) + continue + case RetrySubmission: + s.submitJobQueue <- jobResp.job + continue + } + + verifyJob := espressoVerifyReceiptJob{ + startTime: time.Now(), + transaction: jobResp.job, + hash: jobResp.hash, + } + + select { + case <-s.ctx.Done(): + return + // Move to verifying the receipt + case s.verifyReceiptJobQueue <- verifyJob: + } + } +} + +// Default values for receipt verification tuning are defined as exported +// constants in the espresso package (espresso.DefaultVerifyReceipt*) so that +// the CLI flag defaults and this batcher logic share a single source of truth. + +// evaluateVerification evaluates the verification job response. +// +// # Returns +// +// * If there is no error: Handle. +// +// * If there is a permanent issue that won't be fixed by a retry: Skip. +// +// * If enough HotShot blocks have passed since verification started: RetrySubmission. +// +// * If the wall-clock safety timeout is exceeded: RetrySubmission. +// +// * Otherwise: RetryVerification. +func (s *espressoTransactionSubmitter) evaluateVerification(jobResp espressoVerifyReceiptJobResponse) JobEvaluation { + err := jobResp.err + + // If there's no error, continue handling the verification. + if err == nil { + return Handle + } + + if errors.Is(err, espressoClient.ErrPermanent) { + return Skip + } + + if !errors.Is(err, espressoClient.ErrEphemeral) { + // Log the warning for a potentially missed error handling, but still retry it. + log.Warn("error not explicitly marked as retryable or not", "err", err) + } + + // Block-count-based timeout: re-submit if enough HotShot blocks have + // passed since verification started. The startHeight guard handles the + // edge case where the height tracker hasn't fetched its first value yet. + if jobResp.job.startHeight > 0 && jobResp.currentHeight >= jobResp.job.startHeight+s.verifyReceiptMaxBlocks { + log.Info("Verification timed out by block count, re-submitting", + "startHeight", jobResp.job.startHeight, + "currentHeight", jobResp.currentHeight, + "maxBlocks", s.verifyReceiptMaxBlocks) + return RetrySubmission + } + + // Wall-clock safety backstop in case the block height tracker is stale + // or broken (e.g., query service returning old data). + if elapsed := time.Since(jobResp.job.startTime); elapsed > s.verifyReceiptSafetyTimeout { + log.Warn("Verification timed out by safety timeout, re-submitting", + "elapsed", elapsed, + "safetyTimeout", s.verifyReceiptSafetyTimeout) + return RetrySubmission + } + + // Otherwise, retry the verification. + return RetryVerification +} + +// handleVerifyReceiptJobResponse is a function that is meant to be run in a +// goroutine. +// +// This function handles responses from the verify receipt job queue. It will +// check the results for any errors, and if there are any errors that are +// applicable to retry, it will requeue the job for another attempt. +// If the the job is successful, no further processing is needed and it is +// considered complete. +// If the job has taken too long to verify, then it will re-submit the job +// back to the submit transaction queue for another attempt. +// +// NOTE: This function currently will loop forever if the transaction is +// never going to be available. +func (s *espressoTransactionSubmitter) handleVerifyReceiptJobResponse() { + for { + var jobResp espressoVerifyReceiptJobResponse + var ok bool + + select { + case <-s.ctx.Done(): + return + case jobResp, ok = <-s.verifyReceiptRespQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + switch evaluation := s.evaluateVerification(jobResp); evaluation { + case Skip: + // decrement in flight jobs on skip, since we're done with this job + s.numInFlightJobs.Add(-1) + continue + case RetrySubmission: + s.submitJobQueue <- jobResp.job.transaction + continue + case RetryVerification: + s.verifyReceiptJobQueue <- jobResp.job + continue + } + + s.numInFlightJobs.Add(-1) + // We're done with this job and transaction, we have successfully + // confirmed that the transaction was submitted to Espresso + commitment := jobResp.job.transaction.transaction.Commit() + hash, _ := tagged_base64.New("TX", commitment[:]) + log.Info("Transaction confirmed on Espresso", "hash", hash.String()) + } +} + +// scheduleSubmitTransactionJobs is a function that is meant to be run in a +// goroutine. +// +// It handles the scheduling of submit transaction jobs so that the submit +// transaction workers can process them. +func (s *espressoTransactionSubmitter) scheduleSubmitTransactionJobs() { + for { + var ok bool + + // Get a worker from the worker queue + var worker chan espressoTransactionJobAttempt + select { + case <-s.ctx.Done(): + return + + case worker, ok = <-s.submitWorkerQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // Get a job from the job queue + var job espressoSubmitTransactionJob + select { + case <-s.ctx.Done(): + return + case job, ok = <-s.submitJobQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // Submit the job to the worker + select { + case <-s.ctx.Done(): + return + + case worker <- espressoTransactionJobAttempt{job: job, resp: s.submitRespQueue}: + } + } +} + +// scheduleVerifyReceiptJobs is a function that is meant to be run in a +// goroutine. +// +// It handles the scheduling of verify receipt jobs so that the verify receipt +// workers can process them. +func (s *espressoTransactionSubmitter) scheduleVerifyReceiptsJobs() { + for { + var ok bool + + // Get a worker from the worker queue + var worker chan espressoVerifyReceiptJobAttempt + select { + case <-s.ctx.Done(): + return + + case worker, ok = <-s.verifyReceiptWorkerQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // Get a job from the job queue + var job espressoVerifyReceiptJob + select { + case <-s.ctx.Done(): + return + case job, ok = <-s.verifyReceiptJobQueue: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // Submit the job to the worker + select { + case <-s.ctx.Done(): + return + + case worker <- espressoVerifyReceiptJobAttempt{job: job, resp: s.verifyReceiptRespQueue}: + } + } +} + +// espressoSubmitTransactionWorker is a function that is meant to be run as a +// goroutine. It will create a channel for it's job queue, and submit those to +// the worker queue in order to wait for work. It will then take that job and +// attempt to submit the transaction contained within to espresso using the +// given espresso client. It will submit the response back to the channel +// contained within the job attempt it received. +// +// It's lifetime is governed by the context passed to it, and it will stop +// processing when that context is cancelled. +// +// NOTE: If the context is cancelled after a job has been received, but before +// it is able to submit the transaction, or report about it's result, the job +// may be lost. +func espressoSubmitTransactionWorker( + ctx context.Context, + wg *sync.WaitGroup, + cli espressoClient.EspressoClient, + workerQueue chan<- chan espressoTransactionJobAttempt, +) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer wg.Done() + ch := make(chan espressoTransactionJobAttempt) + defer close(ch) + + for { + var ok bool + select { + case <-ctx.Done(): + return + + // Queue our job queue, asking for work + case workerQueue <- ch: + } + + // Wait for a job to run + var jobAttempt espressoTransactionJobAttempt + select { + case <-ctx.Done(): + return + case jobAttempt, ok = <-ch: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // Submit the transaction to Espresso + hash, err := cli.SubmitTransaction(ctx, *jobAttempt.job.transaction) + if err == nil { + log.Info("Submitted transaction to Espresso", "hash", hash) + } + + jobAttempt.job.attempts++ + resp := espressoSubmitTransactionJobResponse{ + job: jobAttempt.job, + hash: hash, + err: err, + } + + select { + case <-ctx.Done(): + return + + // Send the response back via the channel in the job attempt struct + case jobAttempt.resp <- resp: + } + } +} + +// espressoVerifyTransactionWorker is a function that is meant to be run as a +// goroutine. It will create a channel for it's job queue, and submit those to +// the worker queue in order to wait for work. It will then take that job and +// attempt to verify the transaction contained within to espresso using the +// given espresso client. It will submit the response back to the channel +// contained within the job attempt it received. +func espressoVerifyTransactionWorker( + ctx context.Context, + wg *sync.WaitGroup, + cli espressoClient.EspressoClient, + workerQueue chan<- chan espressoVerifyReceiptJobAttempt, + latestHeight *atomic.Uint64, + retryDelay time.Duration, +) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer wg.Done() + ch := make(chan espressoVerifyReceiptJobAttempt) + defer close(ch) + + for { + var ok bool + select { + case <-ctx.Done(): + return + + // Queue our job queue, asking for work + case workerQueue <- ch: + } + + // Wait for a job to run + var jobAttempt espressoVerifyReceiptJobAttempt + select { + case <-ctx.Done(): + return + case jobAttempt, ok = <-ch: + if !ok { + // Our channel is closed, and we are done + return + } + } + + // On the first attempt, snapshot the current block height so we + // can measure how many blocks pass during verification. + if jobAttempt.job.attempts == 0 { + jobAttempt.job.startHeight = latestHeight.Load() + } + + if jobAttempt.job.attempts > 0 { + // We have already attempted this job, so we will wait a bit + // NOTE: this prevents this worker from being able to process + // other jobs while we wait for this delay. + time.Sleep(retryDelay) + } + + _, err := cli.FetchTransactionByHash(ctx, jobAttempt.job.hash) + + jobAttempt.job.attempts++ + resp := espressoVerifyReceiptJobResponse{ + job: jobAttempt.job, + err: err, + currentHeight: latestHeight.Load(), + } + + select { + case <-ctx.Done(): + return + + case jobAttempt.resp <- resp: + } + } +} + +// SpawnWorkers spawns the given number of workers to process the +// submit transaction jobs and verify receipt jobs. +func (s *espressoTransactionSubmitter) SpawnWorkers(numSubmitTransactionWorkers, numVerifyReceiptWorkers int) { + workersCtx := s.ctx + + for i := 0; i < numSubmitTransactionWorkers; i++ { + s.wg.Add(1) + go espressoSubmitTransactionWorker(workersCtx, s.wg, s.espresso, s.submitWorkerQueue) + } + + for i := 0; i < numVerifyReceiptWorkers; i++ { + s.wg.Add(1) + go espressoVerifyTransactionWorker(workersCtx, s.wg, s.espresso, s.verifyReceiptWorkerQueue, &s.latestBlockHeight, s.verifyReceiptRetryDelay) + } +} + +// trackBlockHeight periodically polls FetchLatestBlockHeight and stores +// the result in s.latestBlockHeight for verify jobs to compare against. +// This avoids redundant height queries from individual verify workers. +func (s *espressoTransactionSubmitter) trackBlockHeight() { + for { + height, err := s.espresso.FetchLatestBlockHeight(s.ctx) + if err == nil { + s.latestBlockHeight.Store(height) + } else if s.ctx.Err() == nil { + log.Debug("failed to fetch latest block height for verification tracking", "err", err) + } + + // Wait for the next interval or until context is done. + select { + case <-time.After(s.verifyReceiptRetryDelay): + case <-s.ctx.Done(): + return + } + } +} + +func (s *espressoTransactionSubmitter) Start() { + // Block height tracker for verify receipt timeout + go s.trackBlockHeight() + + // Submit Transaction Jobs + go s.scheduleSubmitTransactionJobs() + go s.handleTransactionSubmitJobResponse() + + // Verify Receipt Jobs + go s.scheduleVerifyReceiptsJobs() + go s.handleVerifyReceiptJobResponse() +} + +// Converts a block to an EspressoBatch and starts a goroutine that publishes it to Espresso +// Returns error only if batch conversion fails, otherwise it is infallible, as the goroutine +// will retry publishing until successful. +func (l *BatchSubmitter) queueBlockToEspresso(ctx context.Context, block *types.Block) error { + espressoBatch, err := derive.BlockToEspressoBatch(l.RollupConfig, block) + if err != nil { + l.Log.Warn("Failed to derive batch from block", "err", err) + return fmt.Errorf("failed to derive batch from block: %w", err) + } + + transaction, err := espressoBatch.ToEspressoTransaction(ctx, l.RollupConfig.L2ChainID.Uint64(), l.Espresso.ChainSigner) + if err != nil { + l.Log.Warn("Failed to create Espresso transaction from a batch", "err", err) + return fmt.Errorf("failed to create Espresso transaction from a batch: %w", err) + } + + commitment := transaction.Commit() + hash, _ := tagged_base64.New("TX", commitment[:]) + l.Log.Info("Created Espresso transaction from batch", "hash", hash, "batchNr", espressoBatch.BatchHeader.Number.Uint64()) + + if err := l.espressoSubmitter.SubmitTransaction(transaction); err != nil { + return fmt.Errorf("failed to submit job to espresso: %w", err) + } + + return nil +} + +func (l *BatchSubmitter) espressoSyncAndRefresh(ctx context.Context, newSyncStatus *eth.SyncStatus) { + err := l.EspressoStreamer().Refresh(ctx, newSyncStatus.FinalizedL1, newSyncStatus.SafeL2.Number, newSyncStatus.FinalizedL2.L1Origin) + if err != nil { + l.degradedLog.Warn(l.Log, "espressoStreamerRefreshErr", "Failed to refresh Espresso streamer", "err", err) + } else { + l.degradedLog.Clear(l.Log, "espressoStreamerRefreshErr", "Espresso streamer refresh recovered") + } + + l.channelMgrMutex.Lock() + defer l.channelMgrMutex.Unlock() + syncActions, outOfSync := computeSyncActions(*newSyncStatus, l.prevCurrentL1, l.channelMgr.blocks, l.channelMgr.channelQueue, l.Log) + if outOfSync { + l.degradedLog.Warn(l.Log, "sequencerOutOfSync", "Sequencer is out of sync, retrying next tick.") + return + } + l.degradedLog.Clear(l.Log, "sequencerOutOfSync", "Sequencer back in sync") + l.prevCurrentL1 = newSyncStatus.CurrentL1 + if syncActions.clearState != nil { + l.channelMgr.Clear(*syncActions.clearState) + l.EspressoStreamer().Reset() + } else { + l.channelMgr.PruneSafeBlocks(syncActions.blocksToPrune) + l.channelMgr.PruneChannels(syncActions.channelsToPrune) + } +} + +// peekNextBatch returns the next batch from the streamer, performing a fork check +// against an expected parent hash. +// +// The expected parent is tip when tip is set. When tip is zero (channel manager was +// just cleared), we fall back to safeL2.Hash if the batch is at exactly safeL2+1 — +// the one position where we can set tip to the known safe head. Otherwise we accept the batch as-is. +func (l *BatchSubmitter) peekNextBatch(ctx context.Context, syncStatus *eth.SyncStatus) *derive.EspressoBatch { + l.channelMgrMutex.Lock() + tip := l.channelMgr.tip + l.channelMgrMutex.Unlock() + + batch := l.EspressoStreamer().Peek(ctx) + if batch == nil { + return nil + } + + // Check if we can set the tip if not set + if tip == (common.Hash{}) && (*batch).Number() == syncStatus.SafeL2.Number+1 { + l.Log.Info( + "setting tip to safe l2 hash", + "batchNr", (*batch).Number(), + "batchParent", (*batch).Header().ParentHash.Hex(), + "tip", tip, + ) + tip = syncStatus.SafeL2.Hash + } + + if tip == (common.Hash{}) { + l.Log.Warn( + "tip is not set, taking available batch", + "blockParentHash", (*batch).Header().ParentHash.Hex(), + "blockHash", (*batch).Header().Hash().Hex(), + ) + return batch + } + + if (*batch).Header().ParentHash != tip { + l.Log.Warn( + "head batch fork mismatch, seeking to proper head", + "batchNr", (*batch).Number(), + "batchParent", (*batch).Header().ParentHash, + "tip", tip, + ) + l.EspressoStreamer().SetProperHead(tip) + return nil + } + + return batch +} + +// Periodically refreshes the sync status and polls Espresso streamer for new batches +func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync.WaitGroup, publishSignal chan pubInfo) { + l.Log.Info("Starting EspressoBatchLoadingLoop", "polling interval", l.Config.Espresso.PollInterval) + + defer wg.Done() + ticker := time.NewTicker(l.Config.Espresso.PollInterval) + defer ticker.Stop() + defer close(publishSignal) + + for { + select { + case <-ticker.C: + newSyncStatus, err := l.getSyncStatus(ctx) + if err != nil { + l.degradedLog.Warn(l.Log, "syncStatusErr/espressoBatchLoading", "failed to refresh sync status", "err", err) + continue + } + l.degradedLog.Clear(l.Log, "syncStatusErr/espressoBatchLoading", "sync status fetch recovered") + + l.espressoSyncAndRefresh(ctx, newSyncStatus) + + err = l.EspressoStreamer().Update(ctx) + + var batch *derive.EspressoBatch + + for { + + batch = l.peekNextBatch(ctx, newSyncStatus) + + if batch == nil { + break + } + + // This should happen ONLY if the batch is malformed. ToBlock has to guarantee no + // transient errors. + block, err := batch.ToBlock(l.RollupConfig) + if err != nil { + l.Log.Error("failed to convert singular batch to block", "err", err) + l.EspressoStreamer().Next(ctx) + continue + } + + l.Log.Info( + "Received block from Espresso", + "blockNr", block.NumberU64(), + "blockHash", block.Hash(), + "parentHash", block.ParentHash(), + ) + + l.channelMgrMutex.Lock() + err = l.channelMgr.AddL2Block(block) + l.channelMgrMutex.Unlock() + + if err != nil { + l.Log.Error("failed to add L2 block to channel manager", "err", err) + l.clearState(ctx) + l.EspressoStreamer().Reset() + break + } + + l.EspressoStreamer().Next(ctx) + l.Log.Info("Added L2 block to channel manager", "blockNr", block.NumberU64()) + } + + l.tryPublishSignal(publishSignal, pubInfo{}) + + // A failure in the streamer Update can happen after the buffer has been partially filled + if err != nil { + l.degradedLog.Warn(l.Log, "espressoStreamerUpdateErr", "failed to update Espresso streamer", "err", err) + continue + } + l.degradedLog.Clear(l.Log, "espressoStreamerUpdateErr", "Espresso streamer update recovered") + + case <-ctx.Done(): + l.Log.Info("espressoBatchLoadingLoop returning") + return + } + } +} + +type BlockLoader struct { + queuedBlocks []eth.L2BlockRef + prevSyncStatus *eth.SyncStatus + batcher *BatchSubmitter +} + +func (l *BlockLoader) reset(ctx context.Context) { + l.prevSyncStatus = nil + l.queuedBlocks = nil + l.batcher.clearState(ctx) +} + +func (l *BlockLoader) EnqueueBlocks(ctx context.Context, blocksToQueue inclusiveBlockRange) { + l.batcher.Log.Debug("Loading and queueing blocks", "range", blocksToQueue) + for i := blocksToQueue.start; i <= blocksToQueue.end; i++ { + block, err := l.batcher.fetchBlock(ctx, i) + if err != nil { + l.batcher.degradedLog.Warn(l.batcher.Log, "fetchBlockErr", "Failed to fetch block", "err", err) + break + } + l.batcher.degradedLog.Clear(l.batcher.Log, "fetchBlockErr", "Block fetching recovered") + + for _, txn := range block.Transactions() { + l.batcher.Log.Debug("tx hash before submitting to Espresso", "hash", txn.Hash().String()) + } + + if len(l.queuedBlocks) > 0 && block.ParentHash() != l.queuedBlocks[len(l.queuedBlocks)-1].Hash { + l.batcher.Log.Warn("Found L2 reorg", "block_number", i) + l.reset(ctx) + break + } + + blockRef, err := derive.L2BlockToBlockRef(l.batcher.RollupConfig, block) + if err != nil { + // NOTE: if we fail to convert an L2Block to a BlockRef, it's + // unlikely that breaking here, and waiting for resubmission would + // actually ever result in it succeeding. + // For now, we add a log, but this may be a Fatal unrecoverable + // error if it ever occurs. + l.batcher.Log.Warn("failed to convert block to block reference", "err", err) + break + } + + err = l.batcher.queueBlockToEspresso(ctx, block) + if err != nil { + l.batcher.Log.Debug("queue block to espresso failed", "err", err) + break + } + + l.queuedBlocks = append(l.queuedBlocks, blockRef) + } +} + +type EnqueueBlockAction uint + +const ( + ActionEnqueue = iota + ActionRetry + ActionReset +) + +// This function is an analogue of `computeSyncActions` for Espresso batcher mode +// +// It computes the next block range to enqueue to Espresso based on new newSyncStatus and +// does a number of checks to ensure consistency of the chain. +// +// If reorg is detected, empty range and ActionReset is returned. +// If there isn't enough information or no blocks to load yet, empty range and ActionRetry is returned. +func (l *BlockLoader) nextBlockRange(newSyncStatus *eth.SyncStatus) (inclusiveBlockRange, EnqueueBlockAction) { + if newSyncStatus.HeadL1 == (eth.L1BlockRef{}) { + // empty sync status + return inclusiveBlockRange{}, ActionRetry + } + + if l.prevSyncStatus == nil { + l.prevSyncStatus = newSyncStatus + } + + if newSyncStatus.CurrentL1.Number < l.prevSyncStatus.CurrentL1.Number { + // sequencer restarted and hasn't caught up yet + l.batcher.degradedLog.Warn(l.batcher.Log, "sequencerCurrentL1Reversed", "sequencer currentL1 reversed", "new currentL1", newSyncStatus.CurrentL1.Number, "previous currentL1", l.prevSyncStatus.CurrentL1.Number) + return inclusiveBlockRange{}, ActionRetry + } + l.batcher.degradedLog.Clear(l.batcher.Log, "sequencerCurrentL1Reversed", "sequencer currentL1 caught up") + + safeL2 := newSyncStatus.SafeL2 + + // State empty, just enqueue all unsafe blocks + if len(l.queuedBlocks) == 0 { + return inclusiveBlockRange{safeL2.Number + 1, newSyncStatus.UnsafeL2.Number}, ActionEnqueue + } + + lastQueuedBlock := l.queuedBlocks[len(l.queuedBlocks)-1] + firstQueuedBlock := l.queuedBlocks[0] + nextSafeBlockNum := safeL2.Number + 1 + + if lastQueuedBlock.Number >= newSyncStatus.UnsafeL2.Number { + // nothing to enqueue, unsafe block number is not higher than safe + return inclusiveBlockRange{}, ActionRetry + } + + if lastQueuedBlock.Number < safeL2.Number { + // derivation pipeline is somehow ahead of us, reset + return inclusiveBlockRange{}, ActionReset + } + + if nextSafeBlockNum < firstQueuedBlock.Number { + l.batcher.Log.Warn("next safe block is below oldest block in state") + return inclusiveBlockRange{}, ActionReset + } + + numBlocksToEnqueue := nextSafeBlockNum - firstQueuedBlock.Number + + if numBlocksToEnqueue > uint64(len(l.queuedBlocks)) { + l.batcher.Log.Warn("safe head above newest block in state, resetting loader") + return inclusiveBlockRange{}, ActionReset + } + + if numBlocksToEnqueue > 0 && l.queuedBlocks[numBlocksToEnqueue-1].Hash != safeL2.Hash { + l.batcher.Log.Warn("safe chain reorg, resetting loader") + return inclusiveBlockRange{}, ActionReset + } + + if safeL2.Number > firstQueuedBlock.Number { + numFinalizedBlocksInQueue := safeL2.Number - firstQueuedBlock.Number + l.batcher.Log.Warn( + "Removing finalized blocks from queued", + "numFinalizedBlocksInQueue", numFinalizedBlocksInQueue, + "safeL2", safeL2, + "firstQueuedBlock", firstQueuedBlock) + l.queuedBlocks = l.queuedBlocks[numFinalizedBlocksInQueue:] + } + + return inclusiveBlockRange{lastQueuedBlock.Number + 1, newSyncStatus.UnsafeL2.Number}, ActionEnqueue +} + +// numBlocks is a convenience method for inclusiveBlockRange that can be +// utilized to quickly determine how many blocks are being referenced within +// the range. +func (i inclusiveBlockRange) numBlocks() uint64 { + return 1 + i.end - i.start +} + +// LARGE_BLOCK_GAP_THRESHOLD is a threshold for the number of blocks that we +// consider to be a "large gap" when queueing blocks to Espresso. We're +// interested in being alerted when we're falling behind. +const LARGE_BLOCK_GAP_THRESHOLD = 30 * 60 + +// blockLoadingLoop +// - polls the sequencer, +// - queues unsafe blocks from the sequencer to Espresso +func (l *BatchSubmitter) espressoBatchQueueingLoop(ctx context.Context, wg *sync.WaitGroup) { + ticker := time.NewTicker(l.Config.PollInterval) + defer ticker.Stop() + defer wg.Done() + + loader := BlockLoader{ + batcher: l, + } + + // * + // * BEFORE we start: + // * - scan batchInbox from batchInbox.lastBackfilled + // * - enqueue all batches from batchInbox that are _by fallback batcher_ to Espresso + // * - wait for espresso queue to clear + // * - set lastBackfilled to block height of the last of such batches + // * + + for { + select { + case <-ticker.C: + newSyncStatus, err := l.getSyncStatus(ctx) + if err != nil { + l.degradedLog.Warn(l.Log, "syncStatusErr/espressoBatchQueueing", "Couldn't get sync status", "error", err) + continue + } + l.degradedLog.Clear(l.Log, "syncStatusErr/espressoBatchQueueing", "sync status fetch recovered") + + blocksToQueue, action := loader.nextBlockRange(newSyncStatus) + + // We add a check here to add visibility to us that we've exceeded + // a threshold. + if numBlocks := blocksToQueue.numBlocks(); numBlocks >= LARGE_BLOCK_GAP_THRESHOLD { + l.Log.Warn("Large gap of blocks to enqueue to Espresso detected", "numBlocks", numBlocks, "blocksToQueue", blocksToQueue) + } + + if action == ActionEnqueue { + numEnqueuedBlocksBefore := len(loader.queuedBlocks) + loader.EnqueueBlocks(ctx, blocksToQueue) + numEnqueuedBlocksAfter := len(loader.queuedBlocks) + + // This is a check to help us determine whether we're able to + // push through all of the blocks we've attempted to or not. + if (numEnqueuedBlocksAfter - numEnqueuedBlocksBefore) < int(blocksToQueue.numBlocks()) { + // If we're in this conditional, it means we weren't able + // submit all of the blocks to Espresso that we were + // attempting to. + // + // TODO: We should probably throttle a bit. + } + } else if action == ActionReset { + loader.reset(ctx) + } + + case <-ctx.Done(): + l.Log.Info("blockLoadingLoop returning") + return + } + } +} + +func (l *BatchSubmitter) fetchBlock(ctx context.Context, blockNumber uint64) (*types.Block, error) { + l2Client, err := l.EndpointProvider.EthClient(ctx) + if err != nil { + return nil, fmt.Errorf("getting L2 client: %w", err) + } + + cCtx, cancel := context.WithTimeout(ctx, l.Config.NetworkTimeout) + defer cancel() + + block, err := l2Client.BlockByNumber(cCtx, new(big.Int).SetUint64(blockNumber)) + if err != nil { + return nil, fmt.Errorf("getting L2 block: %w", err) + } + + return block, nil +} + +// resolveTEEVerifierAddress queries the BatchAuthenticator contract to get the +// EspressoTEEVerifier address. +func (l *BatchSubmitter) resolveTEEVerifierAddress() error { + if l.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { + // If batcher authenticator address is nil, we will keep teeVerifierAddress to nil as well + return nil + } + auth, err := bindings.NewBatchAuthenticatorCaller(l.RollupConfig.BatchAuthenticatorAddress, l.L1Client) + if err != nil { + return fmt.Errorf("failed to create BatchAuthenticator caller: %w", err) + } + addr, err := auth.EspressoTEEVerifier(nil) + if err != nil { + return fmt.Errorf("failed to query EspressoTEEVerifier address: %w", err) + } + l.teeVerifierAddress = addr + l.Log.Info("Resolved TEE verifier address", "address", addr.Hex()) + return nil +} + +func (l *BatchSubmitter) registerBatcher(ctx context.Context) error { + if len(l.Espresso.Attestation) == 0 { + l.Log.Warn("Attestation is empty, skipping registration") + return nil + } + + if l.Config.Espresso.AttestationService == "" { + l.Log.Warn("EspressoAttestationServices is not set, skipping registration") + return nil + } + + l.Log.Info("Batch authenticator address", "value", l.RollupConfig.BatchAuthenticatorAddress) + code, err := l.L1Client.CodeAt(ctx, l.RollupConfig.BatchAuthenticatorAddress, nil) + if err != nil { + return fmt.Errorf("failed to check code at contract address: %w", err) + } + if len(code) == 0 { + return fmt.Errorf("no contract deployed at this address %w", err) + } + + abi, err := bindings.BatchAuthenticatorMetaData.GetAbi() + if err != nil { + return fmt.Errorf("failed to get Batch Authenticator ABI: %w", err) + } + + onchainProof, err := l.GenerateZKProof(ctx, l.Espresso.Attestation) + if err != nil { + l.Log.Error("failed to generate zk proof from nitro attestation", "err", err) + return fmt.Errorf("failed to generate zk proof from nitro attestation: %w", err) + } + + journalBytes, err := hex.DecodeString(stripHexPrefix(onchainProof.RawProof.Journal)) + if err != nil { + l.Log.Error("failed to decode journal hex string", "err", err) + return fmt.Errorf("failed to decode journal hex string: %w", err) + } + onchainProofBytes, err := hex.DecodeString(stripHexPrefix(onchainProof.OnchainProof)) + if err != nil { + l.Log.Error("failed to decode onchain proof hex string", "err", err) + return fmt.Errorf("failed to decode onchain proof hex string: %w", err) + } + log.Info("successfully generated zk proof from nitro attestation") + + txData, err := abi.Pack("registerSigner", journalBytes, onchainProofBytes) + if err != nil { + return fmt.Errorf("failed to create registerSigner transaction: %w", err) + } + + candidate := txmgr.TxCandidate{ + TxData: txData, + To: &l.RollupConfig.BatchAuthenticatorAddress, + } + + l.Log.Info("Registering batcher with the BatchAuthenticator contract") + _, err = l.Txmgr.Send(ctx, candidate) + if err != nil { + return fmt.Errorf("failed to send registerBatcher transaction: %w", err) + } + + l.Log.Info("Registered batcher with the BatchAuthenticator contract") + + return nil +} + +func (l *BatchSubmitter) GenerateZKProof(ctx context.Context, attestationBytes []byte) (*EspressoOnchainProof, error) { + attestationServiceURL := strings.TrimSuffix(l.Config.Espresso.AttestationService, "/") + url := attestationServiceURL + "/generate_proof" + request, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(attestationBytes)) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/octet-stream") + client := http.Client{ + Timeout: 5 * time.Minute, + } + res, err := client.Do(request) + if err != nil { + return nil, err + } + defer (func() { + _ = res.Body.Close() + })() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-200 response: %d", res.StatusCode) + } + + responseData, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var zkProof EspressoOnchainProof + err = json.Unmarshal(responseData, &zkProof) + if err != nil { + return nil, err + } + + return &zkProof, nil +} + +// sendTxWithEspresso uses the txmgr queue to send the given transaction candidate after setting +// its gaslimit. It will block if the txmgr queue has reached its MaxPendingTransactions limit. +func (l *BatchSubmitter) sendTxWithEspresso(txdata txData, isCancel bool, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { + transactionReference := txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob} + l.Log.Debug("Sending Espresso-enabled L1 transaction", "txRef", transactionReference) + + commitment, err := computeCommitment(candidate) + if err != nil { + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: err, + } + return + } + l.Log.Debug("Computed batch commitment", "txRef", transactionReference, "commitment", hexutil.Encode(commitment[:])) + + signature, err := l.signEIP712Commitment(commitment) + if err != nil { + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("failed to sign transaction: %w", err), + } + return + } + + l.Log.Debug("Signed transaction", "txRef", transactionReference, "commitment", hexutil.Encode(commitment[:]), "sig", hexutil.Encode(signature)) + + batchAuthenticatorAbi, err := bindings.BatchAuthenticatorMetaData.GetAbi() + if err != nil { + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("failed to get batch authenticator ABI: %w", err), + } + return + } + + authenticateBatchCalldata, err := batchAuthenticatorAbi.Pack("authenticateBatchInfo", commitment, signature) + if err != nil { + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("failed to pack authenticateBatch calldata: %w", err), + } + return + } + + verifyCandidate := txmgr.TxCandidate{ + TxData: authenticateBatchCalldata, + To: &l.RollupConfig.BatchAuthenticatorAddress, + } + + l.Log.Debug( + "Sending authenticateBatch transaction", + "txRef", transactionReference, + "commitment", hexutil.Encode(commitment[:]), + "sig", hexutil.Encode(signature), + "address", l.RollupConfig.BatchAuthenticatorAddress.String(), + ) + verificationReceipt, err := l.Txmgr.Send(l.killCtx, verifyCandidate) + if err != nil { + l.Log.Error("Failed to send authenticateBatch transaction", "txRef", transactionReference, "err", err) + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("failed to send authenticateBatch transaction: %w", err), + } + return + } + + receipt, err := l.Txmgr.Send(l.killCtx, *candidate) + if err != nil { + l.Log.Error("Failed to send batch inbox transaction", "txRef", transactionReference, "err", err) + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("failed to send batch inbox transaction: %w", err), + } + return + } + + distance := new(big.Int).Sub(receipt.BlockNumber, verificationReceipt.BlockNumber) + lookbackWindow := new(big.Int).SetUint64(derive.BatchAuthLookbackWindow) + if distance.Sign() < 0 || distance.Cmp(lookbackWindow) >= 0 { + l.Log.Error("authenticateBatch transaction too far from batch inbox transaction", "txRef", transactionReference, "distance", distance) + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Err: fmt.Errorf("authenticateBatch transaction too far from batch inbox transaction: %s", distance), + } + return + } + + receiptsCh <- txmgr.TxReceipt[txRef]{ + ID: transactionReference, + Receipt: receipt, + Err: nil, + } +} + +// signEIP712Commitment creates an EIP-712 signature for the given commitment using the batcher's private key. +func (l *BatchSubmitter) signEIP712Commitment(commitment [32]byte) ([]byte, error) { + typedData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "EspressoTEEVerifier": []apitypes.Type{ + {Name: "commitment", Type: "bytes32"}, + }, + }, + PrimaryType: "EspressoTEEVerifier", + Domain: apitypes.TypedDataDomain{ + Name: "EspressoTEEVerifier", + Version: "1", + ChainId: (*math.HexOrDecimal256)(l.RollupConfig.L1ChainID), + VerifyingContract: l.teeVerifierAddress.String(), + }, + Message: map[string]interface{}{ + "commitment": commitment, + }, + } + // Calculate the hash using go-ethereum's EIP-712 implementation + hash, _, err := apitypes.TypedDataAndHash(typedData) + if err != nil { + return nil, fmt.Errorf("failed to calculate EIP-712 hash: %w", err) + } + + signature, err := crypto.Sign(hash, l.Config.Espresso.BatcherPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to sign EIP-712 hash: %w", err) + } + + // Normalize the recovery ID (v) from 0/1 to 27/28 for Solidity's ECDSA.recover + // See: https://github.com/ethereum/go-ethereum/issues/19751#issuecomment-504900739 + if signature[64] < 27 { + signature[64] += 27 + } + return signature, nil +} + +func stripHexPrefix(hexStr string) string { + if len(hexStr) >= 2 && hexStr[:2] == "0x" { + return hexStr[2:] + } + return hexStr +} diff --git a/op-batcher/batcher/espresso_active.go b/op-batcher/batcher/espresso_active.go index 661e057747b..0557bd792e6 100644 --- a/op-batcher/batcher/espresso_active.go +++ b/op-batcher/batcher/espresso_active.go @@ -5,9 +5,63 @@ import ( "fmt" "time" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/espresso/bindings" ) +// isBatcherActive checks if the current batcher is the active one by querying +// the BatchAuthenticator contract. Returns true if this batcher instance should +// be publishing batches, false if it should stay idle. +// +// The active batcher is determined by the contract's activeIsEspresso flag: +// - If activeIsEspresso is true, the Espresso batcher address is active +// - If activeIsEspresso is false, the fallback batcher address is active +// +// This method compares the batcher's own role (Config.Espresso.Enabled) +// against the contract's activeIsEspresso flag. +func (l *BatchSubmitter) isBatcherActive(ctx context.Context) (bool, error) { + // Check if contract code exists at the address + code, err := l.L1Client.CodeAt(ctx, l.RollupConfig.BatchAuthenticatorAddress, nil) + if err != nil { + return false, fmt.Errorf("failed to check code at BatchAuthenticator address: %w", err) + } + if len(code) == 0 { + return false, fmt.Errorf("no contract code at BatchAuthenticator address %s", l.RollupConfig.BatchAuthenticatorAddress.Hex()) + } + + batchAuthenticator, err := bindings.NewBatchAuthenticator(l.RollupConfig.BatchAuthenticatorAddress, l.L1Client) + if err != nil { + return false, fmt.Errorf("failed to create BatchAuthenticator binding: %w", err) + } + + cCtx, cancel := context.WithTimeout(ctx, l.Config.NetworkTimeout) + defer cancel() + + callOpts := &bind.CallOpts{Context: cCtx} + + activeIsEspresso, err := batchAuthenticator.ActiveIsEspresso(callOpts) + if err != nil { + return false, fmt.Errorf("failed to check activeIsEspresso: %w", err) + } + + batcherAddr := l.Txmgr.From() + + isActive := (activeIsEspresso && l.Config.Espresso.Enabled) || + (!activeIsEspresso && !l.Config.Espresso.Enabled) + + if !isActive { + l.Log.Info("Batcher is not the active batcher, skipping publish", + "batcherAddr", batcherAddr, + "activeIsEspresso", activeIsEspresso, + "EspressoEnabled", l.Config.Espresso.Enabled, + ) + } + + return isActive, nil +} + // isFallbackAuthRequired reports whether the fallback (non-TEE) batcher must // route its batch txs through BatchAuthenticator.authenticateBatchInfo before // posting to the BatchInbox. It returns false if the rollup config has a diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index a2816c64c93..7e800a7dc99 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -1,13 +1,24 @@ package batcher import ( + "context" "fmt" + "math/big" + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + espressoLightClient "github.com/EspressoSystems/espresso-network/sdks/go/light-client" + op "github.com/EspressoSystems/espresso-streamers/op" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/espresso" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) -// waitForAuthGroup blocks until all in-flight fallback-auth watcher goroutines -// have finished. publishingLoop calls it before closing receiptsCh: each watcher +// waitForAuthGroup blocks until all in-flight auth goroutines (fallback watchers +// or TEE submissions) have finished. publishingLoop calls it before closing receiptsCh: each watcher // is a sender on receiptsCh, so the receipts loop must still be draining // receiptsCh at this point or a watcher's final send would block forever. Each // watcher always terminates because the txmgr Queue emits exactly one receipt per @@ -16,19 +27,151 @@ func (l *BatchSubmitter) waitForAuthGroup() { l.authGroup.Wait() } -// dispatchAuthenticatedSendTx routes sendTx through the fallback-batcher -// post-fork auth path, returning true when the tx has been handed off to -// authGroup. Returns false to mean "fall through to the upstream queue.Send -// path" — pre-fork operation and any cancel tx. +// EspressoDriverSetup groups all TEE-batcher-specific runtime state plumbed +// from BatcherService into DriverSetup. Defined here to keep the upstream +// Optimism DriverSetup field block compact (see driver.go). +// +// All fields are nil/zero when --espresso.enabled is false except for the +// fallback batcher's ChainSigner/SequencerAddress, which are always populated +// by applyEspressoDriverSetup. +type EspressoDriverSetup struct { + Client *espressoClient.MultipleNodesClient + LightClient *espressoLightClient.LightclientCaller + ChainSigner opcrypto.ChainSigner + SequencerAddress common.Address + Attestation []byte +} + +// batcherL1Adapter wraps the batcher's L1Client to implement espresso.L1Client +// (HeaderHashByNumber + bind.ContractCaller). +type batcherL1Adapter struct { + L1Client L1Client +} + +func (a *batcherL1Adapter) HeaderHashByNumber(ctx context.Context, number *big.Int) (common.Hash, error) { + h, err := a.L1Client.HeaderByNumber(ctx, number) + if err != nil { + return common.Hash{}, err + } + return h.Hash(), nil +} + +func (a *batcherL1Adapter) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + return a.L1Client.CodeAt(ctx, contract, blockNumber) +} + +func (a *batcherL1Adapter) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + return a.L1Client.CallContract(ctx, call, blockNumber) +} + +// EspressoStreamer returns the Espresso batch streamer for use by the service and tests. +func (l *BatchSubmitter) EspressoStreamer() espresso.EspressoStreamer[derive.EspressoBatch] { + return l.espressoStreamer +} + +// setupEspressoStreamer constructs the Espresso streamer (and its buffered +// wrapper) for a freshly-built BatchSubmitter. Called from NewBatchSubmitter +// only when --espresso.enabled is set; no-op otherwise. Panics on streamer +// construction failure to mirror the existing NewBatchSubmitter behavior. +func (l *BatchSubmitter) setupEspressoStreamer() { + if !l.Config.Espresso.Enabled { + return + } + l1Adapter := &batcherL1Adapter{L1Client: l.L1Client} + // Convert typed nil pointer to untyped nil interface to avoid typed-nil interface panic + // in confirmEspressoBlockHeight when EspressoLightClient is not configured. + var lightClientIface op.LightClientCallerInterface + if l.Espresso.LightClient != nil { + lightClientIface = l.Espresso.LightClient + } + unbufferedStreamer, err := op.NewEspressoStreamer( + l.RollupConfig.L2ChainID.Uint64(), + l1Adapter, + l1Adapter, + l.Espresso.Client, + lightClientIface, + l.Log, + derive.CreateEspressoBatchUnmarshaler(), + l.Config.Espresso.CaffeinationHeightEspresso, + l.Config.Espresso.CaffeinationHeightL2, + l.RollupConfig.BatchAuthenticatorAddress, + false, + ) + if err != nil { + panic(fmt.Sprintf("failed to create Espresso streamer: %v", err)) + } + l.espressoStreamer = op.NewBufferedEspressoStreamer(unbufferedStreamer) + l.Log.Info("Streamer started", "streamer", l.espressoStreamer) +} + +// startEspressoLoops registers the batcher with the BatchAuthenticator +// contract, resolves the TEE verifier address, spawns the Espresso transaction +// submitter, and starts the four Espresso-specific batcher goroutines (in +// addition to the upstream receiptsLoop and publishingLoop). Replaces the +// upstream three-goroutine pattern when --espresso.enabled is set. +func (l *BatchSubmitter) startEspressoLoops(receiptsCh chan txmgr.TxReceipt[txRef], publishSignal chan pubInfo) error { + if err := l.registerBatcher(l.killCtx); err != nil { + return fmt.Errorf("could not register with BatchAuthenticator contract: %w", err) + } + + // Resolve the TEE verifier address from the BatchAuthenticator contract. + if err := l.resolveTEEVerifierAddress(); err != nil { + return fmt.Errorf("could not resolve TEE verifier address: %w", err) + } + + l.espressoSubmitter = NewEspressoTransactionSubmitter( + WithContext(l.shutdownCtx), + WithWaitGroup(l.wg), + WithEspressoClient(l.Espresso.Client), + WithVerifyReceiptMaxBlocks(l.Config.Espresso.VerifyReceiptMaxBlocks), + WithVerifyReceiptSafetyTimeout(l.Config.Espresso.VerifyReceiptSafetyTimeout), + WithVerifyReceiptRetryDelay(l.Config.Espresso.VerifyReceiptRetryDelay), + ) + l.espressoSubmitter.SpawnWorkers(4, 4) + l.espressoSubmitter.Start() + + l.wg.Add(4) + go l.receiptsLoop(l.wg, receiptsCh) // ranges over receiptsCh channel + go l.espressoBatchQueueingLoop(l.shutdownCtx, l.wg) + go l.espressoBatchLoadingLoop(l.shutdownCtx, l.wg, publishSignal) + go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done. + return nil +} + +// resetEspressoStreamer resets the Espresso streamer when --espresso.enabled +// is set; no-op otherwise. Called from clearState alongside the upstream +// channel-manager reset so the streamer's view of "next batch" matches the +// freshly-cleared channel state. +func (l *BatchSubmitter) resetEspressoStreamer() { + if l.Config.Espresso.Enabled { + l.EspressoStreamer().Reset() + } +} + +// dispatchAuthenticatedSendTx routes sendTx through the Espresso (TEE) auth +// path or the fallback-batcher post-fork auth path, returning true when the tx +// has been handed off to authGroup. Returns false to mean "fall through to +// the upstream queue.Send path" — pre-fork fallback batcher and any cancel tx. // -// The fallback batcher consults isFallbackAuthRequired to gate authentication +// The TEE batcher (Config.Espresso.Enabled == true) always authenticates. The +// fallback batcher consults isFallbackAuthRequired to gate authentication // behind the EspressoTime hardfork: pre-fork the verifier accepts plain // sender-authenticated batches, and the BatchAuthenticator contract is -// irrelevant. +// irrelevant; calling authenticateBatchInfo pre-fork would also revert against +// the default activeIsEspresso=true contract state. func (l *BatchSubmitter) dispatchAuthenticatedSendTx(txdata txData, isCancel bool, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) bool { if isCancel { return false } + // Espresso batcher: authenticate via BatchAuthenticator. + if l.Config.Espresso.Enabled { + l.authGroup.Add(1) + go func() { + defer l.authGroup.Done() + l.sendTxWithEspresso(txdata, isCancel, candidate, queue, receiptsCh) + }() + return true + } fallbackAuthRequired, err := l.isFallbackAuthRequired(l.killCtx) if err != nil { receiptsCh <- txmgr.TxReceipt[txRef]{ diff --git a/op-batcher/batcher/espresso_helpers_test.go b/op-batcher/batcher/espresso_helpers_test.go new file mode 100644 index 00000000000..31118a3f80f --- /dev/null +++ b/op-batcher/batcher/espresso_helpers_test.go @@ -0,0 +1,263 @@ +package batcher_test + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "sync" + + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + tagged_base64 "github.com/EspressoSystems/espresso-network/sdks/go/tagged-base64" + "github.com/EspressoSystems/espresso-network/sdks/go/types" + common "github.com/EspressoSystems/espresso-network/sdks/go/types/common" +) + +// ErrNotImplemented is a sentinel error used to indicate that a method +// was not implemented. +var ErrNotImplemented = errors.New("not implemented") + +// AlwaysFailingEspressoClient is a mock implementation of the EspressoClient +// interface that always returns an error for every method call. This is +// useful for testing error handling in the batcher without relying on a +// real Espresso client. +type AlwaysFailingEspressoClient struct{} + +// Compile time check to ensure adherence to EspressoClient interface +var _ espressoClient.EspressoClient = (*AlwaysFailingEspressoClient)(nil) + +func (*AlwaysFailingEspressoClient) FetchLatestBlockHeight(ctx context.Context) (uint64, error) { + return 0, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchHeaderByHeight(ctx context.Context, height uint64) (types.HeaderImpl, error) { + return types.HeaderImpl{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchRawHeaderByHeight(ctx context.Context, height uint64) (json.RawMessage, error) { + return json.RawMessage{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchHeadersByRange(ctx context.Context, from uint64, until uint64) ([]types.HeaderImpl, error) { + return []types.HeaderImpl{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchTransactionsInBlock(ctx context.Context, blockHeight uint64, namespace uint64) (espressoClient.TransactionsInBlock, error) { + return espressoClient.TransactionsInBlock{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchTransactionByHash(ctx context.Context, hash *types.TaggedBase64) (types.TransactionQueryData, error) { + return types.TransactionQueryData{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchVidCommonByHeight(ctx context.Context, blockHeight uint64) (types.VidCommon, error) { + return types.VidCommon{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchExplorerTransactionByHash(ctx context.Context, hash *types.TaggedBase64) (types.ExplorerTransactionQueryData, error) { + return types.ExplorerTransactionQueryData{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) StreamPayloads(ctx context.Context, height uint64) (espressoClient.Stream[types.PayloadQueryData], error) { + return nil, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) StreamTransactions(ctx context.Context, height uint64) (espressoClient.Stream[types.TransactionQueryData], error) { + return nil, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) StreamTransactionsInNamespace(ctx context.Context, height uint64, namespace uint64) (espressoClient.Stream[types.TransactionQueryData], error) { + return nil, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) FetchNamespaceTransactionsInRange(ctx context.Context, fromHeight uint64, toHeight uint64, namespace uint64) ([]types.NamespaceTransactionsRangeData, error) { + return []types.NamespaceTransactionsRangeData{}, ErrNotImplemented +} + +func (*AlwaysFailingEspressoClient) SubmitTransaction(ctx context.Context, tx common.Transaction) (*common.TaggedBase64, error) { + return nil, ErrNotImplemented +} + +// EspressoClientSwappableImplementation is as implementation of EspressoClient +// that is just a proxy. +// +// This allows it to be created and swapped easily as needed for testing. +type EspressoClientSwappableImplementation struct { + sync.RWMutex + espClient espressoClient.EspressoClient +} + +// Compile time check to ensure adherence to EspressoClient interface +var _ espressoClient.EspressoClient = (*EspressoClientSwappableImplementation)(nil) + +func (c *EspressoClientSwappableImplementation) SetEspressoClient(client espressoClient.EspressoClient) { + c.Lock() + defer c.Unlock() + + c.espClient = client +} + +func (c *EspressoClientSwappableImplementation) FetchLatestBlockHeight(ctx context.Context) (uint64, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchLatestBlockHeight(ctx) +} + +func (c *EspressoClientSwappableImplementation) FetchHeaderByHeight(ctx context.Context, height uint64) (types.HeaderImpl, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchHeaderByHeight(ctx, height) +} + +func (c *EspressoClientSwappableImplementation) FetchRawHeaderByHeight(ctx context.Context, height uint64) (json.RawMessage, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchRawHeaderByHeight(ctx, height) +} + +func (c *EspressoClientSwappableImplementation) FetchHeadersByRange(ctx context.Context, from uint64, until uint64) ([]types.HeaderImpl, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchHeadersByRange(ctx, from, until) +} + +func (c *EspressoClientSwappableImplementation) FetchTransactionsInBlock(ctx context.Context, blockHeight uint64, namespace uint64) (espressoClient.TransactionsInBlock, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchTransactionsInBlock(ctx, blockHeight, namespace) +} + +func (c *EspressoClientSwappableImplementation) FetchTransactionByHash(ctx context.Context, hash *types.TaggedBase64) (types.TransactionQueryData, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchTransactionByHash(ctx, hash) +} + +func (c *EspressoClientSwappableImplementation) FetchVidCommonByHeight(ctx context.Context, blockHeight uint64) (types.VidCommon, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchVidCommonByHeight(ctx, blockHeight) +} + +func (c *EspressoClientSwappableImplementation) FetchExplorerTransactionByHash(ctx context.Context, hash *types.TaggedBase64) (types.ExplorerTransactionQueryData, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchExplorerTransactionByHash(ctx, hash) +} + +func (c *EspressoClientSwappableImplementation) StreamPayloads(ctx context.Context, height uint64) (espressoClient.Stream[types.PayloadQueryData], error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.StreamPayloads(ctx, height) +} + +func (c *EspressoClientSwappableImplementation) StreamTransactions(ctx context.Context, height uint64) (espressoClient.Stream[types.TransactionQueryData], error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.StreamTransactions(ctx, height) +} + +func (c *EspressoClientSwappableImplementation) StreamTransactionsInNamespace(ctx context.Context, height uint64, namespace uint64) (espressoClient.Stream[types.TransactionQueryData], error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.StreamTransactionsInNamespace(ctx, height, namespace) +} + +func (c *EspressoClientSwappableImplementation) FetchNamespaceTransactionsInRange(ctx context.Context, fromHeight uint64, toHeight uint64, namespace uint64) ([]types.NamespaceTransactionsRangeData, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.FetchNamespaceTransactionsInRange(ctx, fromHeight, toHeight, namespace) +} + +func (c *EspressoClientSwappableImplementation) SubmitTransaction(ctx context.Context, tx common.Transaction) (*common.TaggedBase64, error) { + c.RLock() + defer c.RUnlock() + + return c.espClient.SubmitTransaction(ctx, tx) +} + +// FakeSubmissionSucceedingEspressoClient is a mock implementation of the +// EspressoClient for the explicit purposes of submitting transactions, and +// seeing their response. +type FakeSubmissionSucceedingEspressoClient struct { + sync.RWMutex + espressoClient.EspressoClient + txns map[string]common.Transaction +} + +// Compile time check to ensure adherence to EspressoClient interface +var _ espressoClient.EspressoClient = (*FakeSubmissionSucceedingEspressoClient)(nil) + +var ( + ErrNotInitialized = errors.New("not initialized") + ErrHashCannotBeNil = errors.New("hash cannot be nil") + ErrTransactionNotFound = errors.New("transaction not found") +) + +func (c *FakeSubmissionSucceedingEspressoClient) Init() { + c.Lock() + defer c.Unlock() + c.txns = make(map[string]common.Transaction) +} + +// FetchTransactionByHash simulates fetching a transaction by its hash. it +// looks up a transaction for the given hash, and returns the transaction +// if it is found. +func (c *FakeSubmissionSucceedingEspressoClient) FetchTransactionByHash(ctx context.Context, hash *types.TaggedBase64) (types.TransactionQueryData, error) { + c.RLock() + defer c.RUnlock() + if c.txns == nil { + return types.TransactionQueryData{}, ErrNotInitialized + } + + if hash == nil { + return types.TransactionQueryData{}, ErrHashCannotBeNil + } + + txn, found := c.txns[hash.String()] + if !found { + return types.TransactionQueryData{}, ErrTransactionNotFound + } + + // Just to simulate some processing on the transaction + height := binary.LittleEndian.Uint64(txn.Payload) + + return types.TransactionQueryData{ + Transaction: txn, + Hash: hash, + Index: 0, + Proof: json.RawMessage{}, + BlockHash: nil, + BlockHeight: height, + }, nil +} + +// SubmitTransaction simulates a successful transaction submission and stores +// it for future retrieval +func (c *FakeSubmissionSucceedingEspressoClient) SubmitTransaction(ctx context.Context, tx common.Transaction) (*common.TaggedBase64, error) { + c.Lock() + defer c.Unlock() + if c.txns == nil { + return nil, ErrNotInitialized + } + + hash, err := tagged_base64.New("TX", tx.Payload) + if err != nil { + return nil, err + } + + c.txns[hash.String()] = tx + return hash, nil +} diff --git a/op-batcher/batcher/espresso_service.go b/op-batcher/batcher/espresso_service.go new file mode 100644 index 00000000000..4217abd8af9 --- /dev/null +++ b/op-batcher/batcher/espresso_service.go @@ -0,0 +1,191 @@ +package batcher + +import ( + "crypto/ecdsa" + "fmt" + "time" + + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + espressoLightClient "github.com/EspressoSystems/espresso-network/sdks/go/light-client" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/hf/nitrite" + + "github.com/ethereum-optimism/optimism/espresso" + "github.com/ethereum-optimism/optimism/op-batcher/enclave" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto" +) + +// EspressoBatcherConfig groups all Espresso-specific configuration the +// batcher consumes at runtime. It is embedded as a single field on +// BatcherConfig (see service.go) to keep the upstream Optimism +// BatcherConfig field block compact and minimize cherry-pick churn. +// +// Fields are populated from CLIConfig.Espresso (and a few RollupConfig +// fallbacks) by initEspresso below; ephemeral key material is generated +// by initKeyPair (TEE) or copied from CLIConfig.Espresso.TestingBatcherPrivateKey +// (devnet/test). +type EspressoBatcherConfig struct { + Enabled bool + PollInterval time.Duration + AttestationService string + CaffeinationHeightEspresso uint64 + // CaffeinationHeightL2 is the L2 batch position at which the Espresso + // streamer should start emitting batches. Operational parameter for + // restarting batchers mid-chain (e.g. after a fallback batcher event). + // When zero, the driver falls back to + // RollupConfig.EspressoOriginBatchPos(). + CaffeinationHeightL2 uint64 + + // Receipt verification tuning for the Espresso transaction submitter. + VerifyReceiptMaxBlocks uint64 + VerifyReceiptSafetyTimeout time.Duration + VerifyReceiptRetryDelay time.Duration + + // BatcherPublicKey/BatcherPrivateKey is the batcher's identity for the + // Espresso authentication path. In TEE deployments the private key is + // generated inside the enclave (initKeyPair) and the public key is + // attested to via Nitro Enclave PCR0; outside TEE (devnet/test), the + // configured TestingBatcherPrivateKey overrides them in initEspresso. + BatcherPublicKey *ecdsa.PublicKey + BatcherPrivateKey *ecdsa.PrivateKey +} + +// EspressoStreamer returns the Espresso batch streamer driven by this batcher. +func (bs *BatcherService) EspressoStreamer() espresso.EspressoStreamer[derive.EspressoBatch] { + return bs.driver.espressoStreamer +} + +// initChainSigner asserts that the configured TxManager implements the +// ChainSigner interface and stores the embedded ChainSigner on the service. +// Espresso uses ChainSigner to sign batch authentication payloads sent to the +// BatchAuthenticator contract; the cast is required by every Espresso path. +func (bs *BatcherService) initChainSigner() error { + cast, castOk := bs.TxManager.(opcrypto.ChainSigner) + if !castOk { + return fmt.Errorf("tx manager does not implement ChainSigner") + } + bs.ChainSigner = cast + return nil +} + +// applyEspressoDriverSetup writes the Espresso-specific fields onto a +// DriverSetup populated with upstream-Optimism fields. Kept separate from the +// main initDriver struct literal so that the upstream block stays in upstream +// shape — minimizing cherry-pick churn when upstream renames or reorders +// fields. +func (bs *BatcherService) applyEspressoDriverSetup(ds *DriverSetup) { + ds.Espresso.SequencerAddress = bs.TxManager.From() + ds.Espresso.ChainSigner = bs.ChainSigner + ds.Espresso.Client = bs.EspressoClient + ds.Espresso.LightClient = bs.EspressoLightClient + ds.Espresso.Attestation = bs.Attestation +} + +// initKeyPair generates an ephemeral ECDSA key pair for the batcher's +// Espresso authentication path. In TEE deployments this key is attested +// to via Nitro Enclave PCR0; outside TEE (devnet/test), the configured +// TestingBatcherPrivateKey overrides this key in initEspresso. +func (bs *BatcherService) initKeyPair() error { + key, err := crypto.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate key pair for batcher: %w", err) + } + bs.Espresso.BatcherPrivateKey = key + bs.Espresso.BatcherPublicKey = &key.PublicKey + return nil +} + +// initEspresso configures the Espresso TEE-batcher integration on the +// BatcherService. When --espresso.enabled is false this is a no-op (the +// fallback batcher gets its own FallbackAuthLeadTime knob from +// BatcherConfig). When enabled, it wires up the Espresso query-service +// client, light client, ephemeral key pair, and Nitro Enclave attestation +// (if running in TEE). +func (bs *BatcherService) initEspresso(cfg *CLIConfig) error { + if !cfg.Espresso.Enabled { + return nil + } + + if cfg.Espresso.RollupL1URL == "" { + cfg.Espresso.RollupL1URL = cfg.L1EthRpc + } + + if cfg.Espresso.RollupL1URL != cfg.L1EthRpc { + log.Warn("Espresso Rollup L1 URL differs from batcher's L1EthRpc") + } + + if cfg.Espresso.L1URL == "" { + log.Warn("Espresso L1 URL not provided, using batcher's L1EthRpc") + cfg.Espresso.L1URL = cfg.L1EthRpc + } + if cfg.Espresso.Namespace == 0 { + log.Info("Using L2 chain ID as namespace by default") + cfg.Espresso.Namespace = bs.RollupConfig.L2ChainID.Uint64() + } + if cfg.Espresso.BatchAuthenticatorAddr == (common.Address{}) { + cfg.Espresso.BatchAuthenticatorAddr = bs.RollupConfig.BatchAuthenticatorAddress + } + + if err := cfg.Espresso.Check(); err != nil { + return fmt.Errorf("invalid Espresso config: %w", err) + } + + bs.Espresso.Enabled = true + bs.Espresso.PollInterval = cfg.Espresso.PollInterval + bs.Espresso.AttestationService = cfg.Espresso.EspressoAttestationService + bs.Espresso.CaffeinationHeightEspresso = cfg.Espresso.CaffeinationHeightEspresso + bs.Espresso.CaffeinationHeightL2 = cfg.Espresso.CaffeinationHeightL2 + bs.Espresso.VerifyReceiptMaxBlocks = cfg.Espresso.VerifyReceiptMaxBlocks + bs.Espresso.VerifyReceiptSafetyTimeout = cfg.Espresso.VerifyReceiptSafetyTimeout + bs.Espresso.VerifyReceiptRetryDelay = cfg.Espresso.VerifyReceiptRetryDelay + + client, err := espressoClient.NewMultipleNodesClient(cfg.Espresso.QueryServiceURLs) + if err != nil { + return fmt.Errorf("failed to create Espresso client: %w", err) + } + bs.EspressoClient = client + + lightClient, err := espressoLightClient.NewLightclientCaller(cfg.Espresso.LightClientAddr, bs.L1Client) + if err != nil { + return fmt.Errorf("failed to create Espresso light client: %w", err) + } + bs.EspressoLightClient = lightClient + + if err := bs.initKeyPair(); err != nil { + return fmt.Errorf("failed to create key pair for batcher: %w", err) + } + + // try to generate attestationBytes on public key when start batcher + attestationBytes, err := enclave.AttestationWithPublicKey(bs.Espresso.BatcherPublicKey) + if err != nil { + bs.Log.Info("Not running in enclave, skipping attestation", "info", err) + + // Replace ephemeral keys with configured keys, as in devnet they'll be pre-approved for batching + privateKey := cfg.Espresso.TestingBatcherPrivateKey + if privateKey == nil { + return fmt.Errorf("when not running in enclave, testing batcher private key should be set") + } + + publicKey := privateKey.Public() + publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("error casting public key to ECDSA") + } + + bs.Espresso.BatcherPrivateKey = privateKey + bs.Espresso.BatcherPublicKey = publicKeyECDSA + } else { + // output length of attestation + bs.Log.Info("Successfully got attestation. Attestation length", "length", len(attestationBytes)) + _, err := nitrite.Verify(attestationBytes, nitrite.VerifyOptions{}) + if err != nil { + return fmt.Errorf("Couldn't verify attestation: %w", err) + } + bs.Attestation = attestationBytes + } + + return nil +} diff --git a/op-batcher/batcher/espresso_transaction_submitter_test.go b/op-batcher/batcher/espresso_transaction_submitter_test.go new file mode 100644 index 00000000000..6c736bdbe3b --- /dev/null +++ b/op-batcher/batcher/espresso_transaction_submitter_test.go @@ -0,0 +1,177 @@ +package batcher_test + +import ( + "context" + "encoding/binary" + "testing" + "time" + + espressoCommon "github.com/EspressoSystems/espresso-network/sdks/go/types" + "github.com/ethereum-optimism/optimism/op-batcher/batcher" + "github.com/stretchr/testify/require" +) + +const NUMBER_OF_TRANSACTIONS_TO_SUBMIT = 1024 * 4 + +// TestEspressoTransactionSubmitterDeadlock is a test that is meant to test +// the underlying conditions in the espressoTransactionSubmitter that may +// result in a deadlock. +// +// The basic idea is to intentionally trigger the circumstances that can +// trigger the deadlock to occur in the espressoTransactionSubmitter. This +// test with this description **SHOULD** remain as a regression test. +// +// The specific suspected criteria for triggering a deadlock in the +// implementation of the espressoTransactionSubmitter as of 2026-04-23 is +// as follows: +// +// - Assume that the Espresso Service is not ever returning successfully +// - Assume we are receiving a consistent stream of of new blocks coming in +// +// If we have both of these criteria, it should be possible to fill up the +// underlying channels of `espressoTransactionSubmitter` for both Submission +// jobs, and verification jobs. +// +// The way we *should* be able to detect the deadlock is if a call to +// SubmitTransaction blocks. +// +// NOTE: After this has been fixed, it is unlikely that this will fail in the +// future. This will primarily be due to `SubmitTransaction` being modified +// to longer explicitly block. +func TestEspressoTransactionSubmitterDeadlock(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + submitter := batcher.NewEspressoTransactionSubmitter( + batcher.WithEspressoClient(new(AlwaysFailingEspressoClient)), + batcher.WithContext(ctx), + ) + + submitter.SpawnWorkers(4, 4) + submitter.Start() + + for i := 0; i < NUMBER_OF_TRANSACTIONS_TO_SUBMIT; i++ { + txn := espressoCommon.Transaction{ + Namespace: 1, + Payload: make([]byte, 8), + } + binary.LittleEndian.PutUint64(txn.Payload, uint64(i)) + + submitCtx, submitCancel := context.WithCancel(context.Background()) + go (func(cancel context.CancelFunc, txn espressoCommon.Transaction) { + submitter.SubmitTransaction(&txn) + cancel() + })(submitCancel, txn) + + timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + + select { + case <-submitCtx.Done(): + // Things progressed without issue. + timeoutCancel() + submitCancel() + continue + case <-timeoutCtx.Done(): + // The call to SubmitTransaction did not return within the expected + // time frame. + + timeoutCancel() + submitCancel() + t.Fatalf("SubmitTransaction has blocked for longer than 200 milliseconds, on transaction %d, for error: %s\n", i, timeoutCtx.Err()) + } + } + + // If we've gotten here, we have passed without deadlocking. +} + +// TestEspressoTransactionSubmitterProgress is a test that is meant to test +// and verify the modified behavior of the espressoTransactionSubmitter after +// the behavior change to explicitly address the potential for the previous +// deadlock condition. +// +// The resolution is to target and limit the number of active inflight requests +// pending to be submitted and verified on Espresso at a given time. We target +// this behavior explicitly by being able to configure a maximum number of +// pending or "inflight" requests that are being waited on. By setting this +// limit, and being able to track things going through the pipeline, we can +// ensure that we continue to make progress, and put back pressure on the +// submitter themselves, so that they don't keep trying to submit new +// transactions if we are unable to effectively handle them at our current +// capacity. +// +// This test ensures that this workflow can be processed by first starting with +// a failing EspressoClient, and once we hit the threshold of having too +// many in flight requests, we swap the Client over to a succeeding client, +// and ensure that we're able to get through our pending backlog, and all new +// transactions to submit without stalling. +func TestEspressoTransactionSubmitterProgress(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + failingClient := new(AlwaysFailingEspressoClient) + succeedingClient := new(FakeSubmissionSucceedingEspressoClient) + succeedingClient.Init() + succeedingClient.EspressoClient = failingClient + espClient := new(EspressoClientSwappableImplementation) + espClient.SetEspressoClient(failingClient) + submitter := batcher.NewEspressoTransactionSubmitter( + batcher.WithEspressoClient(espClient), + batcher.WithContext(ctx), + ) + + submitter.SpawnWorkers(4, 4) + submitter.Start() + + i := 0 + for ; i < NUMBER_OF_TRANSACTIONS_TO_SUBMIT; i++ { + txn := espressoCommon.Transaction{ + Namespace: 1, + Payload: make([]byte, 8), + } + binary.LittleEndian.PutUint64(txn.Payload, uint64(i)) + err := submitter.SubmitTransaction(&txn) + + if err == nil { + continue + } + + // We've triggered the initial condition, and we're no longer + if _, ok := err.(batcher.ErrTooManyInFlightRequests); ok { + // able to submit new transactions to the queue, as we've filled + // the in flight capacity. + // NOTE: we decrement `i` here, as we didn't successfully submit it. + i-- + break + } + + require.NoError(t, err, "unexpected error encountered while submit transaction has been called") + } + + // Now we trigger the EspressoClient to start working again. + // This will effectively simulate that the external network has + // no recovered. + + espClient.SetEspressoClient(succeedingClient) + + // We need to wait a little bit to give the submitter time to process + // some of its backlog. + + time.Sleep(10 * time.Millisecond) + + for ; i < NUMBER_OF_TRANSACTIONS_TO_SUBMIT; i++ { + txn := espressoCommon.Transaction{ + Namespace: 1, + Payload: make([]byte, 8), + } + binary.LittleEndian.PutUint64(txn.Payload, uint64(i)) + err := submitter.SubmitTransaction(&txn) + + if _, ok := err.(batcher.ErrTooManyInFlightRequests); ok { + // Slow down a bit, and decrement `i` so we try it again. + i-- + time.Sleep(10 * time.Millisecond) + continue + } + + // No further errors should occur + require.NoError(t, err, "unexpected error encountered while submit transaction has been called") + } +} diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 72ad9806b18..7db4d6866e3 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -8,6 +8,8 @@ import ( "sync/atomic" "time" + espressoClient "github.com/EspressoSystems/espresso-network/sdks/go/client" + espressoLightClient "github.com/EspressoSystems/espresso-network/sdks/go/light-client" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/log" @@ -20,6 +22,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/cliapp" + opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/dial" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/httputil" @@ -58,6 +61,11 @@ type BatcherConfig struct { // and verifier evaluation time (containing L1 block). See // isFallbackAuthRequired in espresso_active.go for details. FallbackAuthLeadTime time.Duration + + // Espresso groups all TEE-batcher-specific configuration. Defined in + // espresso_service.go to keep the upstream Optimism field block compact. + // Zero-valued when --espresso.enabled=false. + Espresso EspressoBatcherConfig } // BatcherService represents a full batch-submitter instance and its resources, @@ -88,6 +96,14 @@ type BatcherService struct { stopped atomic.Bool NotSubmittingOnStart bool + + // Espresso runtime state. Defined in espresso_service.go to keep the + // upstream Optimism field block compact. EspressoClient and + // EspressoLightClient are nil when --espresso.enabled=false. + EspressoClient *espressoClient.MultipleNodesClient + EspressoLightClient *espressoLightClient.LightclientCaller + opcrypto.ChainSigner + Attestation []byte } type DriverSetupOption func(setup *DriverSetup) @@ -195,6 +211,9 @@ func (bs *BatcherService) initFromCLIConfig(ctx context.Context, closeApp contex if err := bs.initPProf(cfg); err != nil { return fmt.Errorf("failed to init profiling: %w", err) } + if err := bs.initEspresso(cfg); err != nil { + return fmt.Errorf("failed to init Espresso: %w", err) + } bs.initDriver(opts...) if err := bs.initRPCServer(cfg); err != nil { return fmt.Errorf("failed to start RPC server: %w", err) @@ -367,6 +386,9 @@ func (bs *BatcherService) initTxManager(_ context.Context, cfg *CLIConfig) error return err } bs.TxManager = txManager + if err := bs.initChainSigner(); err != nil { + return err + } return nil } @@ -419,6 +441,7 @@ func (bs *BatcherService) initDriver(opts ...DriverSetupOption) { ChannelConfig: bs.ChannelConfig, AltDA: bs.AltDA, } + bs.applyEspressoDriverSetup(&ds) for _, opt := range opts { opt(&ds) } diff --git a/op-batcher/flags/flags.go b/op-batcher/flags/flags.go index 352c9076b3b..103d606bccb 100644 --- a/op-batcher/flags/flags.go +++ b/op-batcher/flags/flags.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v2" + "github.com/ethereum-optimism/optimism/espresso" altda "github.com/ethereum-optimism/optimism/op-alt-da" "github.com/ethereum-optimism/optimism/op-batcher/compressor" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" @@ -214,6 +215,7 @@ func init() { optionalFlags = append(optionalFlags, oppprof.CLIFlags(EnvVarPrefix)...) optionalFlags = append(optionalFlags, txmgr.CLIFlagsWithBTO(EnvVarPrefix)...) optionalFlags = append(optionalFlags, altda.CLIFlags(EnvVarPrefix, "")...) + optionalFlags = append(optionalFlags, espresso.CLIFlags(EnvVarPrefix, "")...) Flags = append(requiredFlags, optionalFlags...) } diff --git a/op-service/log/repeat_state.go b/op-service/log/repeat_state.go new file mode 100644 index 00000000000..2147a05fec6 --- /dev/null +++ b/op-service/log/repeat_state.go @@ -0,0 +1,91 @@ +package log + +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/log" +) + +// RepeatStateReminderInterval is how often a long-running degraded state +// re-emits a Warn so operators don't lose visibility while the state persists. +const RepeatStateReminderInterval = 5 * time.Minute + +// RepeatStateLogger collapses warnings tied to a "degraded state" into a single +// log on entry, periodic reminders while the state persists, and a recovery +// log on exit. This avoids flooding the log debouncer when a tick-driven loop +// fires the same warning every poll interval. +// +// State is keyed by a free-form string supplied by the caller; entries with +// different keys are independent. Safe for concurrent use. +// +// Unlike DebouncingHandler (which operates at the slog.Handler level on a +// short time window), RepeatStateLogger is caller-driven: the caller signals +// state recovery explicitly via Clear, which lets it emit a recovery log a +// handler-level facility can't produce. +type RepeatStateLogger struct { + mu sync.Mutex + states map[string]*repeatStateEntry + clock func() time.Time +} + +type repeatStateEntry struct { + firstSeen time.Time + lastLogged time.Time + occurrences int +} + +func NewRepeatStateLogger() *RepeatStateLogger { + return &RepeatStateLogger{ + states: make(map[string]*repeatStateEntry), + clock: time.Now, + } +} + +// Warn reports an observation of a degraded state. The first observation since +// the most recent Clear (or first ever for the key) emits at warn level. +// Subsequent observations within RepeatStateReminderInterval are silently +// counted; once the interval has elapsed a single reminder warn is emitted +// with the cumulative occurrence count and the total duration since the state +// became active. +func (r *RepeatStateLogger) Warn(l log.Logger, key, msg string, ctx ...any) { + now := r.clock() + r.mu.Lock() + e, active := r.states[key] + if !active { + r.states[key] = &repeatStateEntry{firstSeen: now, lastLogged: now, occurrences: 1} + r.mu.Unlock() + l.Warn(msg, ctx...) + return + } + e.occurrences++ + if now.Sub(e.lastLogged) < RepeatStateReminderInterval { + r.mu.Unlock() + return + } + occurrences := e.occurrences + duration := now.Sub(e.firstSeen).Round(time.Second) + e.lastLogged = now + r.mu.Unlock() + + l.Warn(msg, append([]any{"occurrences", occurrences, "duration", duration}, ctx...)...) +} + +// Clear marks the named state as resolved. If the state was active a single +// info-level recovery log is emitted summarising the duration and total +// occurrences. Calling Clear when the state is not active is a no-op, so it is +// safe to call on every successful tick of the loop. +func (r *RepeatStateLogger) Clear(l log.Logger, key, recoveryMsg string, ctx ...any) { + r.mu.Lock() + e, active := r.states[key] + delete(r.states, key) + r.mu.Unlock() + if !active { + return + } + args := append([]any{ + "duration", r.clock().Sub(e.firstSeen).Round(time.Second), + "occurrences", e.occurrences, + }, ctx...) + l.Info(recoveryMsg, args...) +} diff --git a/op-service/log/repeat_state_test.go b/op-service/log/repeat_state_test.go new file mode 100644 index 00000000000..1d74dc2a7be --- /dev/null +++ b/op-service/log/repeat_state_test.go @@ -0,0 +1,234 @@ +package log + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/log" +) + +// safeTestRecorder is a thread-safe slog.Handler that captures records for +// assertions in concurrent tests. +type safeTestRecorder struct { + mu sync.Mutex + records []slog.Record +} + +func (r *safeTestRecorder) Enabled(context.Context, slog.Level) bool { return true } + +func (r *safeTestRecorder) Handle(_ context.Context, rec slog.Record) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, rec) + return nil +} + +func (r *safeTestRecorder) WithAttrs([]slog.Attr) slog.Handler { return r } +func (r *safeTestRecorder) WithGroup(string) slog.Handler { return r } + +func (r *safeTestRecorder) GetRecords() []slog.Record { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]slog.Record, len(r.records)) + copy(result, r.records) + return result +} + +func (r *safeTestRecorder) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.records) +} + +// fakeClock returns the time stored at *t, allowing the test to advance time +// deterministically without sleeping. +func fakeClock(now *time.Time) func() time.Time { + return func() time.Time { return *now } +} + +// matchingRecords returns records whose level and message match the given +// level and msg. Pass an empty msg to match any message. +func matchingRecords(records []slog.Record, level slog.Level, msg string) []slog.Record { + var out []slog.Record + for _, r := range records { + if r.Level != level { + continue + } + if msg != "" && r.Message != msg { + continue + } + out = append(out, r) + } + return out +} + +// attrValue extracts the value of the named attribute, or nil if absent. +func attrValue(r slog.Record, key string) any { + var v any + r.Attrs(func(a slog.Attr) bool { + if a.Key == key { + v = a.Value.Any() + return false + } + return true + }) + return v +} + +func newCapturingLogger() (log.Logger, *safeTestRecorder) { + rec := new(safeTestRecorder) + return log.NewLogger(rec), rec +} + +func TestRepeatStateLogger_FirstWarnEmitsThenSuppresses(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + r.Warn(lgr, "k1", "degraded", "err", "boom") + now = now.Add(1 * time.Second) + r.Warn(lgr, "k1", "degraded", "err", "boom") + now = now.Add(1 * time.Second) + r.Warn(lgr, "k1", "degraded", "err", "boom") + + warns := matchingRecords(rec.GetRecords(), slog.LevelWarn, "degraded") + require.Len(t, warns, 1, "only the first observation should emit a log") + + require.Equal(t, "boom", attrValue(warns[0], "err")) + require.Nil(t, attrValue(warns[0], "occurrences"), "first emission should not carry an occurrences count") +} + +func TestRepeatStateLogger_ReminderAfterInterval(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + // Initial emission. + r.Warn(lgr, "k1", "degraded") + + // 9 silent observations, every 30s. Cumulative 4m30s, still under the 5m threshold. + for i := 0; i < 9; i++ { + now = now.Add(30 * time.Second) + r.Warn(lgr, "k1", "degraded") + } + warns := matchingRecords(rec.GetRecords(), slog.LevelWarn, "degraded") + require.Len(t, warns, 1, "no reminder before the interval has elapsed") + + // Cross the threshold. + now = now.Add(31 * time.Second) + r.Warn(lgr, "k1", "degraded") + + warns = matchingRecords(rec.GetRecords(), slog.LevelWarn, "degraded") + require.Len(t, warns, 2, "reminder should fire once the interval has elapsed") + + // Reminder reports cumulative occurrences: 1 initial + 9 silent + 1 reminder = 11. + require.EqualValues(t, int64(11), attrValue(warns[1], "occurrences")) + // Duration since firstSeen is 9*30s + 31s = 5m1s, rounded to nearest second. + require.Equal(t, 5*time.Minute+1*time.Second, attrValue(warns[1], "duration")) +} + +func TestRepeatStateLogger_KeysAreIndependent(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + r.Warn(lgr, "k1", "first state") + r.Warn(lgr, "k2", "second state") + // Both keys are now active. Repeats for either should be suppressed. + r.Warn(lgr, "k1", "first state") + r.Warn(lgr, "k2", "second state") + + require.Len(t, matchingRecords(rec.GetRecords(), slog.LevelWarn, "first state"), 1) + require.Len(t, matchingRecords(rec.GetRecords(), slog.LevelWarn, "second state"), 1) + + // Clearing one key must not affect the other. + r.Clear(lgr, "k1", "first recovered") + r.Warn(lgr, "k2", "second state") // still suppressed + require.Len(t, matchingRecords(rec.GetRecords(), slog.LevelWarn, "second state"), 1) +} + +func TestRepeatStateLogger_ClearEmitsRecoveryWhenActive(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + r.Warn(lgr, "k1", "degraded") + now = now.Add(2 * time.Second) + r.Warn(lgr, "k1", "degraded") // suppressed, but increments totalOccurrences + now = now.Add(3 * time.Second) + r.Clear(lgr, "k1", "recovered", "extra", "ctx") + + infos := matchingRecords(rec.GetRecords(), slog.LevelInfo, "recovered") + require.Len(t, infos, 1) + require.Equal(t, 5*time.Second, attrValue(infos[0], "duration")) + require.EqualValues(t, int64(2), attrValue(infos[0], "occurrences")) + require.Equal(t, "ctx", attrValue(infos[0], "extra")) +} + +func TestRepeatStateLogger_ClearWhenInactiveIsNoop(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + r.Clear(lgr, "k1", "recovered") + + require.Empty(t, matchingRecords(rec.GetRecords(), slog.LevelInfo, "recovered")) +} + +func TestRepeatStateLogger_FreshAfterClear(t *testing.T) { + lgr, rec := newCapturingLogger() + now := time.Unix(0, 0) + r := NewRepeatStateLogger() + r.clock = fakeClock(&now) + + r.Warn(lgr, "k1", "degraded") + r.Warn(lgr, "k1", "degraded") // suppressed + r.Clear(lgr, "k1", "recovered") + + // After Clear, the next Warn should emit again as a fresh first observation. + r.Warn(lgr, "k1", "degraded") + + warns := matchingRecords(rec.GetRecords(), slog.LevelWarn, "degraded") + require.Len(t, warns, 2, "first Warn after Clear should emit") + require.Nil(t, attrValue(warns[1], "occurrences"), "fresh emission should not carry an occurrences count") +} + +func TestRepeatStateLogger_ConcurrentCallersDoNotRace(t *testing.T) { + lgr, _ := newCapturingLogger() + r := NewRepeatStateLogger() + + const goroutines = 32 + const callsPerG = 200 + + var wg sync.WaitGroup + wg.Add(goroutines) + var clears atomic.Int64 + for g := 0; g < goroutines; g++ { + go func(id int) { + defer wg.Done() + key := "k" + string(rune('a'+(id%4))) + for i := 0; i < callsPerG; i++ { + r.Warn(lgr, key, "degraded", "g", id) + if i%50 == 0 { + r.Clear(lgr, key, "recovered") + clears.Add(1) + } + } + }(g) + } + wg.Wait() + // No assertion on counts — this test exists to flush out races under -race + // and to assert the logger does not panic under concurrent Warn/Clear. + require.Greater(t, clears.Load(), int64(0)) +} From 9bc46e0bbd5466d9958fb13d0070da659c283081 Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Thu, 18 Jun 2026 16:23:33 +0200 Subject: [PATCH 06/13] op-batcher: route TEE auth through ordered tx queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TEE batcher's Espresso submission path called Txmgr.Send directly for both the authenticateBatchInfo tx and the batch inbox tx, and ran inside authGroup, so it bypassed MaxPendingTransactions, assigned nonces nondeterministically (violating Holocene's in-order L1 inclusion requirement), and never checked whether the auth tx reverted — a reverted authenticateBatchInfo emits no event, so the verifier would silently drop the batch. Submit both txs through the ordered queue.Send path on the publishing-loop goroutine (auth first, batch second) so the auth tx takes the lower nonce and is mined first, and both stay under MaxPendingTransactions. A watcher goroutine (tracked by authGroup) collects both receipts, fails the pair if the auth tx reverted, runs the lookback-window check, and emits a single synthetic receipt. This is the same fix already applied to the fallback path; extract the shared submission + receipt-watching flow into submitAuthenticatedBatch / watchAuthReceipts so both paths reuse it and differ only in how the authenticateBatchInfo calldata is built (TEE-attested signature vs empty signature). Rename fallback_auth.go to espresso_auth.go to reflect the shared scope, and restructure the tests: one suite drives the shared flow directly, plus per-path tests asserting the distinguishing auth calldata (empty sig vs a recoverable EIP-712 signature). Co-authored-by: OpenCode --- op-batcher/batcher/espresso.go | 54 +-- .../{fallback_auth.go => espresso_auth.go} | 39 +- op-batcher/batcher/espresso_auth_test.go | 379 ++++++++++++++++++ op-batcher/batcher/espresso_driver.go | 6 +- op-batcher/batcher/fallback_auth_test.go | 251 ------------ 5 files changed, 414 insertions(+), 315 deletions(-) rename op-batcher/batcher/{fallback_auth.go => espresso_auth.go} (73%) create mode 100644 op-batcher/batcher/espresso_auth_test.go delete mode 100644 op-batcher/batcher/fallback_auth_test.go diff --git a/op-batcher/batcher/espresso.go b/op-batcher/batcher/espresso.go index 5825626bf11..00d19f15526 100644 --- a/op-batcher/batcher/espresso.go +++ b/op-batcher/batcher/espresso.go @@ -1338,10 +1338,14 @@ func (l *BatchSubmitter) GenerateZKProof(ctx context.Context, attestationBytes [ return &zkProof, nil } -// sendTxWithEspresso uses the txmgr queue to send the given transaction candidate after setting -// its gaslimit. It will block if the txmgr queue has reached its MaxPendingTransactions limit. +// sendTxWithEspresso authenticates a batch transaction via the BatchAuthenticator contract using +// a TEE-attested EIP-712 signature, then sends the batch data to the BatchInbox address. Both txs +// are submitted through the ordered txmgr queue (auth first, batch second) so they are mined in +// submission order, as required under Holocene, and both stay under MaxPendingTransactions +// (queue.Send blocks when the queue is full). A watcher goroutine collects both receipts and +// emits a single synthetic receipt for the batch txData. func (l *BatchSubmitter) sendTxWithEspresso(txdata txData, isCancel bool, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { - transactionReference := txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob} + transactionReference := txRef{id: txdata.ID(), isCancel: isCancel, isBlob: txdata.daType == DaTypeBlob, daType: txdata.daType, size: txdata.Len()} l.Log.Debug("Sending Espresso-enabled L1 transaction", "txRef", transactionReference) commitment, err := computeCommitment(candidate) @@ -1388,49 +1392,7 @@ func (l *BatchSubmitter) sendTxWithEspresso(txdata txData, isCancel bool, candid To: &l.RollupConfig.BatchAuthenticatorAddress, } - l.Log.Debug( - "Sending authenticateBatch transaction", - "txRef", transactionReference, - "commitment", hexutil.Encode(commitment[:]), - "sig", hexutil.Encode(signature), - "address", l.RollupConfig.BatchAuthenticatorAddress.String(), - ) - verificationReceipt, err := l.Txmgr.Send(l.killCtx, verifyCandidate) - if err != nil { - l.Log.Error("Failed to send authenticateBatch transaction", "txRef", transactionReference, "err", err) - receiptsCh <- txmgr.TxReceipt[txRef]{ - ID: transactionReference, - Err: fmt.Errorf("failed to send authenticateBatch transaction: %w", err), - } - return - } - - receipt, err := l.Txmgr.Send(l.killCtx, *candidate) - if err != nil { - l.Log.Error("Failed to send batch inbox transaction", "txRef", transactionReference, "err", err) - receiptsCh <- txmgr.TxReceipt[txRef]{ - ID: transactionReference, - Err: fmt.Errorf("failed to send batch inbox transaction: %w", err), - } - return - } - - distance := new(big.Int).Sub(receipt.BlockNumber, verificationReceipt.BlockNumber) - lookbackWindow := new(big.Int).SetUint64(derive.BatchAuthLookbackWindow) - if distance.Sign() < 0 || distance.Cmp(lookbackWindow) >= 0 { - l.Log.Error("authenticateBatch transaction too far from batch inbox transaction", "txRef", transactionReference, "distance", distance) - receiptsCh <- txmgr.TxReceipt[txRef]{ - ID: transactionReference, - Err: fmt.Errorf("authenticateBatch transaction too far from batch inbox transaction: %s", distance), - } - return - } - - receiptsCh <- txmgr.TxReceipt[txRef]{ - ID: transactionReference, - Receipt: receipt, - Err: nil, - } + l.submitAuthenticatedBatch(transactionReference, verifyCandidate, candidate, queue, receiptsCh) } // signEIP712Commitment creates an EIP-712 signature for the given commitment using the batcher's private key. diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/espresso_auth.go similarity index 73% rename from op-batcher/batcher/fallback_auth.go rename to op-batcher/batcher/espresso_auth.go index 61a3de51641..bd018fa5464 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/espresso_auth.go @@ -79,44 +79,57 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca To: &l.RollupConfig.BatchAuthenticatorAddress, } + l.submitAuthenticatedBatch(transactionReference, verifyCandidate, candidate, queue, receiptsCh) +} + +// submitAuthenticatedBatch submits an authenticateBatchInfo tx (verifyCandidate) followed by the +// batch inbox tx (candidate) through the ordered txmgr queue, then spawns a watcher (tracked by +// authGroup so the publishing loop drains it before closing receiptsCh) that validates both +// receipts and emits a single synthetic receipt for the batch txData. +// +// Both the TEE and fallback auth paths share this submission flow; they differ only in how the +// authenticateBatchInfo calldata in verifyCandidate is built (TEE-attested signature vs empty +// signature relying on the contract's msg.sender check). +// +// Both queue.Send calls run on the caller's (publishing-loop) goroutine so the auth tx is +// assigned the lower, earlier-mined nonce and both txs stay under MaxPendingTransactions +// (queue.Send blocks when the queue is full); as required under Holocene the auth tx must land +// before the batch tx. +func (l *BatchSubmitter) submitAuthenticatedBatch(transactionReference txRef, verifyCandidate txmgr.TxCandidate, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { // Private buffered channels: queue.Send forwards exactly one receipt to each, so the watcher // reads exactly once per channel (even on context cancellation, the queue still emits a - // ctx-error receipt). These never reach handleReceipt; only the synthetic receipt below does. + // ctx-error receipt). These never reach handleReceipt; only the synthetic receipt does. authReceiptCh := make(chan txmgr.TxReceipt[txRef], 1) batchReceiptCh := make(chan txmgr.TxReceipt[txRef], 1) l.Log.Debug( - "Sending fallback authenticateBatchInfo transaction", + "Sending authenticateBatchInfo transaction", "txRef", transactionReference, - "commitment", hexutil.Encode(commitment[:]), "address", l.RollupConfig.BatchAuthenticatorAddress.String(), ) - // Submit the auth tx then the batch tx, in order, on the publishing-loop goroutine so their - // nonces are assigned in submission order. Each Send blocks here when the queue is at its - // MaxPendingTransactions limit. queue.Send(transactionReference, verifyCandidate, authReceiptCh) queue.Send(transactionReference, *candidate, batchReceiptCh) l.authGroup.Add(1) go func() { defer l.authGroup.Done() - l.watchFallbackAuthReceipts(transactionReference, authReceiptCh, batchReceiptCh, receiptsCh) + l.watchAuthReceipts(transactionReference, authReceiptCh, batchReceiptCh, receiptsCh) }() } -// watchFallbackAuthReceipts collects the auth and batch receipts for a fallback-auth pair, +// watchAuthReceipts collects the auth and batch receipts for an authenticated batch pair, // validates that the batch tx landed within the lookback window of the auth tx, and forwards a // single synthetic receipt keyed to the batch txData onto receiptsCh. Any failure produces an // error receipt so the channel manager rewinds and resubmits the frame set. -func (l *BatchSubmitter) watchFallbackAuthReceipts(transactionReference txRef, authReceiptCh, batchReceiptCh chan txmgr.TxReceipt[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { +func (l *BatchSubmitter) watchAuthReceipts(transactionReference txRef, authReceiptCh, batchReceiptCh chan txmgr.TxReceipt[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { authResult := <-authReceiptCh batchResult := <-batchReceiptCh if authResult.Err != nil { - l.Log.Error("Failed to send fallback authenticateBatchInfo transaction", "txRef", transactionReference, "err", authResult.Err) + l.Log.Error("Failed to send authenticateBatchInfo transaction", "txRef", transactionReference, "err", authResult.Err) receiptsCh <- txmgr.TxReceipt[txRef]{ ID: transactionReference, - Err: fmt.Errorf("failed to send fallback authenticateBatchInfo transaction: %w", authResult.Err), + Err: fmt.Errorf("failed to send authenticateBatchInfo transaction: %w", authResult.Err), } return } @@ -127,10 +140,10 @@ func (l *BatchSubmitter) watchFallbackAuthReceipts(transactionReference txRef, a // batch inbox tx needs no such check: derivation reads its data by L1 inclusion, not by // execution status. if authResult.Receipt.Status != types.ReceiptStatusSuccessful { - l.Log.Error("Fallback authenticateBatchInfo transaction reverted", "txRef", transactionReference, "txHash", authResult.Receipt.TxHash) + l.Log.Error("authenticateBatchInfo transaction reverted", "txRef", transactionReference, "txHash", authResult.Receipt.TxHash) receiptsCh <- txmgr.TxReceipt[txRef]{ ID: transactionReference, - Err: fmt.Errorf("fallback authenticateBatchInfo transaction reverted: %s", authResult.Receipt.TxHash), + Err: fmt.Errorf("authenticateBatchInfo transaction reverted: %s", authResult.Receipt.TxHash), } return } diff --git a/op-batcher/batcher/espresso_auth_test.go b/op-batcher/batcher/espresso_auth_test.go new file mode 100644 index 00000000000..828d349376f --- /dev/null +++ b/op-batcher/batcher/espresso_auth_test.go @@ -0,0 +1,379 @@ +package batcher + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/espresso/bindings" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/txmgr" +) + +var errSendFailed = errors.New("send failed") + +// recordedSend captures a single queue.Send invocation. +type recordedSend struct { + candidate txmgr.TxCandidate + receiptCh chan txmgr.TxReceipt[txRef] +} + +// fakeTxSender records Send calls in order and immediately delivers a canned +// response (by index) on the receipt channel, mimicking the txmgr Queue, which +// forwards exactly one receipt per Send. +type fakeTxSender struct { + sends []recordedSend + responses []txmgr.TxReceipt[txRef] +} + +func (f *fakeTxSender) Send(id txRef, candidate txmgr.TxCandidate, receiptCh chan txmgr.TxReceipt[txRef]) { + idx := len(f.sends) + f.sends = append(f.sends, recordedSend{candidate: candidate, receiptCh: receiptCh}) + resp := f.responses[idx] + resp.ID = id + receiptCh <- resp +} + +func newAuthSubmitter(t *testing.T) *BatchSubmitter { + l := &BatchSubmitter{} + l.Log = testlog.Logger(t, log.LevelDebug) + l.RollupConfig = &rollup.Config{ + BatchAuthenticatorAddress: common.HexToAddress("0x00000000000000000000000000000000000000aa"), + } + return l +} + +func testAuthTxData(t *testing.T) txData { + return singleFrameTxData(frameData{data: []byte("frame-data")}) +} + +func receiptWithBlock(num int64) *types.Receipt { + return &types.Receipt{BlockNumber: big.NewInt(num), Status: types.ReceiptStatusSuccessful} +} + +func revertedReceiptWithBlock(num int64) *types.Receipt { + return &types.Receipt{BlockNumber: big.NewInt(num), Status: types.ReceiptStatusFailed} +} + +// authTxRef builds the txRef the auth paths key their receipts under. +func authTxRef(txdata txData) txRef { + return txRef{id: txdata.ID(), isBlob: txdata.daType == DaTypeBlob, daType: txdata.daType, size: txdata.Len()} +} + +// The following suite drives submitAuthenticatedBatch / watchAuthReceipts directly. This is the +// flow shared by both the fallback and TEE auth paths (they differ only in how the auth calldata +// is built; see TestFallbackAuth_Calldata / TestEspressoAuth_Calldata for that distinction). + +// TestSubmitAuthenticatedBatch_OrderingAndSuccess verifies the auth tx is submitted before the +// batch tx (so it takes the lower nonce and lands first, as required under Holocene) and that a +// single success receipt for the batch txData is emitted when both txs land within the lookback +// window. +func TestSubmitAuthenticatedBatch_OrderingAndSuccess(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + ref := authTxRef(txdata) + verifyCandidate := txmgr.TxCandidate{TxData: []byte("auth-calldata"), To: &l.RollupConfig.BatchAuthenticatorAddress} + candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, // auth + {Receipt: receiptWithBlock(101)}, // batch + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(ref, verifyCandidate, candidate, queue, receiptsCh) + l.authGroup.Wait() + + require.Len(t, queue.sends, 2) + // First send is the auth tx, giving it the lower, earlier-mined nonce. + require.Equal(t, verifyCandidate.TxData, queue.sends[0].candidate.TxData) + // Second send is the batch tx itself. + require.Equal(t, candidate.TxData, queue.sends[1].candidate.TxData) + + got := <-receiptsCh + require.NoError(t, got.Err) + require.Equal(t, receiptWithBlock(101).BlockNumber, got.Receipt.BlockNumber) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +func TestSubmitAuthenticatedBatch_AuthSendFailureRetried(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Err: errSendFailed}, // auth fails to send + {Receipt: receiptWithBlock(101)}, // batch lands anyway + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(authTxRef(txdata), txmgr.TxCandidate{}, &txmgr.TxCandidate{}, queue, receiptsCh) + l.authGroup.Wait() + + got := <-receiptsCh + require.Error(t, got.Err) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +func TestSubmitAuthenticatedBatch_BatchSendFailureRetried(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, // auth lands + {Err: errSendFailed}, // batch fails to send + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(authTxRef(txdata), txmgr.TxCandidate{}, &txmgr.TxCandidate{}, queue, receiptsCh) + l.authGroup.Wait() + + got := <-receiptsCh + require.Error(t, got.Err) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +// TestSubmitAuthenticatedBatch_AuthRevertedRetried verifies that an authenticateBatchInfo tx that +// mines but reverts (no event emitted for the verifier) produces an error receipt so the frames +// are re-queued, rather than being confirmed as success. +func TestSubmitAuthenticatedBatch_AuthRevertedRetried(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: revertedReceiptWithBlock(100)}, // auth mined but reverted + {Receipt: receiptWithBlock(101)}, // batch lands + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(authTxRef(txdata), txmgr.TxCandidate{}, &txmgr.TxCandidate{}, queue, receiptsCh) + l.authGroup.Wait() + + got := <-receiptsCh + require.Error(t, got.Err) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +// TestSubmitAuthenticatedBatch_WindowViolationRetried verifies that a batch tx landing outside the +// lookback window of the auth tx produces an error receipt (so the channel manager rewinds and +// resubmits), rather than being confirmed. +func TestSubmitAuthenticatedBatch_WindowViolationRetried(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + + tooFar := int64(100 + derive.BatchAuthLookbackWindow + 1) + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, // auth + {Receipt: receiptWithBlock(tooFar)}, // batch too far away + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(authTxRef(txdata), txmgr.TxCandidate{}, &txmgr.TxCandidate{}, queue, receiptsCh) + l.authGroup.Wait() + + got := <-receiptsCh + require.Error(t, got.Err) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +// TestSubmitAuthenticatedBatch_WindowBoundaryAccepted pins the inclusive bound of the +// lookback window: a batch landing exactly BatchAuthLookbackWindow blocks after the auth +// tx is still accepted by the verifier (CollectAuthenticatedBatches scans +// [batchBlock - BatchAuthLookbackWindow, batchBlock]), so the batcher must not +// re-queue it. +func TestSubmitAuthenticatedBatch_WindowBoundaryAccepted(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + + boundary := int64(100 + derive.BatchAuthLookbackWindow) + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, // auth + {Receipt: receiptWithBlock(boundary)}, // batch at the exact edge of the window + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.submitAuthenticatedBatch(authTxRef(txdata), txmgr.TxCandidate{}, &txmgr.TxCandidate{}, queue, receiptsCh) + l.authGroup.Wait() + + got := <-receiptsCh + require.NoError(t, got.Err) + require.Equal(t, receiptWithBlock(boundary).BlockNumber, got.Receipt.BlockNumber) + require.Equal(t, txdata.ID().String(), got.ID.id.String()) +} + +// unpackAuthenticateBatchInfo decodes an authenticateBatchInfo(bytes32,bytes) calldata blob into +// its commitment and signature arguments. +func unpackAuthenticateBatchInfo(t *testing.T, calldata []byte) (commitment [32]byte, signature []byte) { + abi, err := bindings.BatchAuthenticatorMetaData.GetAbi() + require.NoError(t, err) + method, ok := abi.Methods["authenticateBatchInfo"] + require.True(t, ok) + require.Equal(t, method.ID, calldata[:4], "calldata is not an authenticateBatchInfo call") + + args, err := method.Inputs.Unpack(calldata[4:]) + require.NoError(t, err) + require.Len(t, args, 2) + return args[0].([32]byte), args[1].([]byte) +} + +// TestFallbackAuth_Calldata verifies the distinguishing behavior of the fallback path: the auth tx +// it submits is authenticateBatchInfo(commitment, emptySig), relying on the contract's msg.sender +// check rather than a signature. +func TestFallbackAuth_Calldata(t *testing.T) { + l := newAuthSubmitter(t) + txdata := testAuthTxData(t) + candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, + {Receipt: receiptWithBlock(101)}, + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) + l.authGroup.Wait() + + require.Len(t, queue.sends, 2) + require.NotNil(t, queue.sends[0].candidate.To) + require.Equal(t, l.RollupConfig.BatchAuthenticatorAddress, *queue.sends[0].candidate.To) + + expectedCommitment, err := computeCommitment(candidate) + require.NoError(t, err) + commitment, signature := unpackAuthenticateBatchInfo(t, queue.sends[0].candidate.TxData) + require.Equal(t, expectedCommitment, commitment) + require.Empty(t, signature, "fallback path must send an empty signature") +} + +// TestEspressoAuth_Calldata verifies the distinguishing behavior of the TEE path: the auth tx it +// submits is authenticateBatchInfo(commitment, sig) where sig is a 65-byte EIP-712 signature over +// the commitment that recovers to the batcher's key. +func TestEspressoAuth_Calldata(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + + l := newAuthSubmitter(t) + l.RollupConfig.L1ChainID = big.NewInt(1) + l.teeVerifierAddress = common.HexToAddress("0x00000000000000000000000000000000000000bb") + l.Config.Espresso.BatcherPrivateKey = key + + txdata := testAuthTxData(t) + candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} + + queue := &fakeTxSender{ + responses: []txmgr.TxReceipt[txRef]{ + {Receipt: receiptWithBlock(100)}, + {Receipt: receiptWithBlock(101)}, + }, + } + receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) + + l.sendTxWithEspresso(txdata, false, candidate, queue, receiptsCh) + l.authGroup.Wait() + + require.Len(t, queue.sends, 2) + require.NotNil(t, queue.sends[0].candidate.To) + require.Equal(t, l.RollupConfig.BatchAuthenticatorAddress, *queue.sends[0].candidate.To) + + expectedCommitment, err := computeCommitment(candidate) + require.NoError(t, err) + commitment, signature := unpackAuthenticateBatchInfo(t, queue.sends[0].candidate.TxData) + require.Equal(t, expectedCommitment, commitment) + require.Len(t, signature, 65, "TEE path must send a 65-byte EIP-712 signature") + + // Reconstruct the EIP-712 digest the batcher signed and confirm the signature recovers to the + // batcher's key. + typedData := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "EspressoTEEVerifier": []apitypes.Type{ + {Name: "commitment", Type: "bytes32"}, + }, + }, + PrimaryType: "EspressoTEEVerifier", + Domain: apitypes.TypedDataDomain{ + Name: "EspressoTEEVerifier", + Version: "1", + ChainId: (*math.HexOrDecimal256)(l.RollupConfig.L1ChainID), + VerifyingContract: l.teeVerifierAddress.String(), + }, + Message: map[string]interface{}{ + "commitment": commitment, + }, + } + digest, _, err := apitypes.TypedDataAndHash(typedData) + require.NoError(t, err) + + // Denormalize v (27/28 -> 0/1) before recovering, undoing the Solidity-compat normalization. + recoverSig := make([]byte, 65) + copy(recoverSig, signature) + require.Contains(t, []byte{27, 28}, recoverSig[64]) + recoverSig[64] -= 27 + + recovered, err := crypto.SigToPub(digest, recoverSig) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(key.PublicKey), crypto.PubkeyToAddress(*recovered)) +} + +// TestComputeCommitment_Parity locks the batcher's batch-commitment computation to +// the verifier's. The batcher must hash exactly what op-node derivation hashes, or +// post-fork batches fail the commitment match and are silently dropped. It checks +// both paths against derive.ComputeCalldataBatchHash / derive.ComputeBlobBatchHash; +// for blobs it uses the real versioned hashes the tx will carry (via MakeSidecar, +// the same hashes the verifier reads from tx.BlobHashes()). +func TestComputeCommitment_Parity(t *testing.T) { + t.Run("calldata", func(t *testing.T) { + for _, data := range [][]byte{[]byte("batch calldata payload"), {}, nil} { + got, err := computeCommitment(&txmgr.TxCandidate{TxData: data}) + require.NoError(t, err) + require.Equal(t, derive.ComputeCalldataBatchHash(data), common.Hash(got)) + } + }) + + t.Run("blobs", func(t *testing.T) { + for _, n := range []int{1, 3} { + blobs := make([]*eth.Blob, n) + for i := range blobs { + var blob eth.Blob + // Distinct first byte per blob so the versioned hashes differ and the + // concatenation order is actually exercised. + require.NoError(t, blob.FromData(eth.Data{byte(i), 0xab, 0xcd})) + blobs[i] = &blob + } + _, blobHashes, err := txmgr.MakeSidecar(blobs, false) + require.NoError(t, err) + + got, err := computeCommitment(&txmgr.TxCandidate{Blobs: blobs}) + require.NoError(t, err) + require.Equal(t, derive.ComputeBlobBatchHash(blobHashes), common.Hash(got)) + } + }) +} diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 7e800a7dc99..1f2fc09aed3 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -165,11 +165,7 @@ func (l *BatchSubmitter) dispatchAuthenticatedSendTx(txdata txData, isCancel boo } // Espresso batcher: authenticate via BatchAuthenticator. if l.Config.Espresso.Enabled { - l.authGroup.Add(1) - go func() { - defer l.authGroup.Done() - l.sendTxWithEspresso(txdata, isCancel, candidate, queue, receiptsCh) - }() + l.sendTxWithEspresso(txdata, isCancel, candidate, queue, receiptsCh) return true } fallbackAuthRequired, err := l.isFallbackAuthRequired(l.killCtx) diff --git a/op-batcher/batcher/fallback_auth_test.go b/op-batcher/batcher/fallback_auth_test.go deleted file mode 100644 index 0822f8a091f..00000000000 --- a/op-batcher/batcher/fallback_auth_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package batcher - -import ( - "errors" - "math/big" - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/log" - "github.com/stretchr/testify/require" - - "github.com/ethereum-optimism/optimism/op-node/rollup" - "github.com/ethereum-optimism/optimism/op-node/rollup/derive" - "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/testlog" - "github.com/ethereum-optimism/optimism/op-service/txmgr" -) - -var errSendFailed = errors.New("send failed") - -// recordedSend captures a single queue.Send invocation. -type recordedSend struct { - candidate txmgr.TxCandidate - receiptCh chan txmgr.TxReceipt[txRef] -} - -// fakeTxSender records Send calls in order and immediately delivers a canned -// response (by index) on the receipt channel, mimicking the txmgr Queue, which -// forwards exactly one receipt per Send. -type fakeTxSender struct { - sends []recordedSend - responses []txmgr.TxReceipt[txRef] -} - -func (f *fakeTxSender) Send(id txRef, candidate txmgr.TxCandidate, receiptCh chan txmgr.TxReceipt[txRef]) { - idx := len(f.sends) - f.sends = append(f.sends, recordedSend{candidate: candidate, receiptCh: receiptCh}) - resp := f.responses[idx] - resp.ID = id - receiptCh <- resp -} - -func newFallbackAuthSubmitter(t *testing.T) *BatchSubmitter { - l := &BatchSubmitter{} - l.Log = testlog.Logger(t, log.LevelDebug) - l.RollupConfig = &rollup.Config{ - BatchAuthenticatorAddress: common.HexToAddress("0x00000000000000000000000000000000000000aa"), - } - return l -} - -func testFallbackTxData(t *testing.T) txData { - return singleFrameTxData(frameData{data: []byte("frame-data")}) -} - -func receiptWithBlock(num int64) *types.Receipt { - return &types.Receipt{BlockNumber: big.NewInt(num), Status: types.ReceiptStatusSuccessful} -} - -func revertedReceiptWithBlock(num int64) *types.Receipt { - return &types.Receipt{BlockNumber: big.NewInt(num), Status: types.ReceiptStatusFailed} -} - -// TestFallbackAuth_OrderingAndSuccess verifies the auth tx is submitted before -// the batch tx (so it takes the lower nonce and lands first, as Espresso -// requires) and that a single success receipt for the batch txData is emitted -// when both txs land within the lookback window. -func TestFallbackAuth_OrderingAndSuccess(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Receipt: receiptWithBlock(100)}, // auth - {Receipt: receiptWithBlock(101)}, // batch - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - require.Len(t, queue.sends, 2) - // First send must target the BatchAuthenticator (the auth tx), giving it the - // lower, earlier-mined nonce. - require.NotNil(t, queue.sends[0].candidate.To) - require.Equal(t, l.RollupConfig.BatchAuthenticatorAddress, *queue.sends[0].candidate.To) - // Second send is the batch tx itself. - require.Equal(t, candidate.TxData, queue.sends[1].candidate.TxData) - - got := <-receiptsCh - require.NoError(t, got.Err) - require.Equal(t, receiptWithBlock(101).BlockNumber, got.Receipt.BlockNumber) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -func TestFallbackAuth_AuthFailureRetried(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Err: errSendFailed}, // auth fails - {Receipt: receiptWithBlock(101)}, // batch lands anyway - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - got := <-receiptsCh - require.Error(t, got.Err) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -func TestFallbackAuth_BatchFailureRetried(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Receipt: receiptWithBlock(100)}, // auth lands - {Err: errSendFailed}, // batch fails - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - got := <-receiptsCh - require.Error(t, got.Err) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -// TestFallbackAuth_AuthRevertedRetried verifies that an authenticateBatchInfo tx -// that mines but reverts (no event emitted for the verifier) produces an error -// receipt so the frames are re-queued, rather than being confirmed as success. -func TestFallbackAuth_AuthRevertedRetried(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Receipt: revertedReceiptWithBlock(100)}, // auth mined but reverted - {Receipt: receiptWithBlock(101)}, // batch lands - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - got := <-receiptsCh - require.Error(t, got.Err) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -// TestFallbackAuth_WindowViolationRetried verifies that a batch tx landing -// outside the lookback window of the auth tx produces an error receipt (so the -// channel manager rewinds and resubmits), rather than being confirmed. -func TestFallbackAuth_WindowViolationRetried(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - tooFar := int64(100 + derive.BatchAuthLookbackWindow + 1) - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Receipt: receiptWithBlock(100)}, // auth - {Receipt: receiptWithBlock(tooFar)}, // batch too far away - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - got := <-receiptsCh - require.Error(t, got.Err) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -// TestFallbackAuth_WindowBoundaryAccepted pins the inclusive bound of the lookback -// window: a batch landing exactly BatchAuthLookbackWindow blocks after the auth tx is -// still accepted by the verifier (CollectAuthenticatedBatches scans -// [batchBlock - BatchAuthLookbackWindow, batchBlock]), so the batcher must not -// re-queue it. -func TestFallbackAuth_WindowBoundaryAccepted(t *testing.T) { - l := newFallbackAuthSubmitter(t) - txdata := testFallbackTxData(t) - candidate := &txmgr.TxCandidate{TxData: []byte("batch-calldata")} - - boundary := int64(100 + derive.BatchAuthLookbackWindow) - queue := &fakeTxSender{ - responses: []txmgr.TxReceipt[txRef]{ - {Receipt: receiptWithBlock(100)}, // auth - {Receipt: receiptWithBlock(boundary)}, // batch at the exact edge of the window - }, - } - receiptsCh := make(chan txmgr.TxReceipt[txRef], 1) - - l.sendTxWithFallbackAuth(txdata, false, candidate, queue, receiptsCh) - l.authGroup.Wait() - - got := <-receiptsCh - require.NoError(t, got.Err) - require.Equal(t, receiptWithBlock(boundary).BlockNumber, got.Receipt.BlockNumber) - require.Equal(t, txdata.ID().String(), got.ID.id.String()) -} - -// TestComputeCommitment_Parity locks the batcher's batch-commitment computation to -// the verifier's. The batcher must hash exactly what op-node derivation hashes, or -// post-fork batches fail the commitment match and are silently dropped. It checks -// both paths against derive.ComputeCalldataBatchHash / derive.ComputeBlobBatchHash; -// for blobs it uses the real versioned hashes the tx will carry (via MakeSidecar, -// the same hashes the verifier reads from tx.BlobHashes()). -func TestComputeCommitment_Parity(t *testing.T) { - t.Run("calldata", func(t *testing.T) { - for _, data := range [][]byte{[]byte("batch calldata payload"), {}, nil} { - got, err := computeCommitment(&txmgr.TxCandidate{TxData: data}) - require.NoError(t, err) - require.Equal(t, derive.ComputeCalldataBatchHash(data), common.Hash(got)) - } - }) - - t.Run("blobs", func(t *testing.T) { - for _, n := range []int{1, 3} { - blobs := make([]*eth.Blob, n) - for i := range blobs { - var blob eth.Blob - // Distinct first byte per blob so the versioned hashes differ and the - // concatenation order is actually exercised. - require.NoError(t, blob.FromData(eth.Data{byte(i), 0xab, 0xcd})) - blobs[i] = &blob - } - _, blobHashes, err := txmgr.MakeSidecar(blobs, false) - require.NoError(t, err) - - got, err := computeCommitment(&txmgr.TxCandidate{Blobs: blobs}) - require.NoError(t, err) - require.Equal(t, derive.ComputeBlobBatchHash(blobHashes), common.Hash(got)) - } - }) -} From ab139069b43a0f6a883770104d8af66374ec7da3 Mon Sep 17 00:00:00 2001 From: Artemii Gerasimovich Date: Thu, 18 Jun 2026 17:00:03 +0200 Subject: [PATCH 07/13] op-node: add EspressoBatch roundtrip test, fix derive_test vet failure Port TestBatchRoundtrip from the integration branch and fold it into espresso_batch_test.go. It is the only test covering ToEspressoTransaction and the batcher->derivation serialization path; it asserts the decoded batch matches the original and that the recovered signer is the batcher. Also drop the decodedBlock.ExecutionWitness() comparison in TestEspressoBatchConversion: that method does not exist on the op-geth types.Block pinned in the rebase-18 base, so go vet of the derive_test package failed to build. EspressoBatch/ToBlock carries no execution witness. Co-authored-by: OpenCode --- op-node/rollup/derive/espresso_batch_test.go | 64 ++++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/op-node/rollup/derive/espresso_batch_test.go b/op-node/rollup/derive/espresso_batch_test.go index 05ef9c2f9d3..a97fcc417f1 100644 --- a/op-node/rollup/derive/espresso_batch_test.go +++ b/op-node/rollup/derive/espresso_batch_test.go @@ -2,6 +2,7 @@ package derive_test import ( "bytes" + "context" "math/big" "math/rand" "slices" @@ -11,13 +12,22 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup" derive "github.com/ethereum-optimism/optimism/op-node/rollup/derive" dtest "github.com/ethereum-optimism/optimism/op-node/rollup/derive/test" + "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/signer" + "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum/go-ethereum/common" gethTypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" + gethCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" ) +const ( + testMnemonic = "test test test test test test test test test test test junk" + testHDPath = "m/44'/60'/0'/0/1" +) + var defaultTestRollUpConfig = &rollup.Config{ Genesis: rollup.Genesis{L2: eth.BlockID{Number: 0}}, L2ChainID: big.NewInt(1234), @@ -89,7 +99,7 @@ func TestUnmarshalEspressoTransactionTooShort(t *testing.T) { cases := [][]byte{ nil, {}, - make([]byte, crypto.SignatureLength-1), + make([]byte, gethCrypto.SignatureLength-1), } for _, data := range cases { _, err := derive.UnmarshalEspressoTransaction(data) @@ -151,10 +161,6 @@ func TestEspressoBatchConversion(t *testing.T) { t.Errorf("decoded block excess blob gas mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) } - if have, want := decodedBlock.ExecutionWitness(), originalBlock.ExecutionWitness(); have != want { - t.Errorf("decoded block execution witness mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) - } - if have, want := decodedBlock.Extra(), originalBlock.Extra(); !bytes.Equal(have, want) { t.Errorf("decoded block extra mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) } @@ -235,3 +241,49 @@ func TestEspressoBatchConversion(t *testing.T) { t.Errorf("decoded block withdrawals root mismatch:\nhave:\n\t\"%v\"\nwant:\n\t\"%v\"\n", have, want) } } + +// TestBatchRoundtrip exercises the batcher serialization path +// (BlockToEspressoBatch -> ToEspressoTransaction) against the derivation +// deserialization path (UnmarshalEspressoTransaction): a block packed and signed +// by the batcher must decode back to an equivalent batch, and the signer address +// recovered from the payload signature must match the batcher's address. +func TestBatchRoundtrip(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + + originalBlock := dtest.RandomL2BlockWithChainIdAndTime(rng, 10, defaultTestRollUpConfig.L2ChainID, time.Now()) + + batch, err := derive.BlockToEspressoBatch(defaultTestRollUpConfig, originalBlock) + require.NoError(t, err, "failed to convert block to batch") + + signerFactory, batcherAddress, err := crypto.ChainSignerFactoryFromConfig( + testlog.Logger(t, log.LevelDebug), + "", + testMnemonic, + testHDPath, + signer.NewCLIConfig(), + ) + require.NoError(t, err, "failed to build chain signer factory") + chainSigner := signerFactory(defaultTestRollUpConfig.L2ChainID, batcherAddress) + + transaction, err := batch.ToEspressoTransaction( + context.Background(), + defaultTestRollUpConfig.L2ChainID.Uint64(), + chainSigner, + ) + require.NoError(t, err, "failed to serialize batch to Espresso transaction") + + decodedBatch, err := derive.UnmarshalEspressoTransaction(transaction.Payload) + require.NoError(t, err, "failed to deserialize Espresso transaction back to batch") + + // The signer recovered from the payload signature must be the batcher. + require.Equal(t, batcherAddress, decodedBatch.SignerAddress, "recovered signer address mismatch") + + // The decoded batch must be equivalent to the original (the recovered + // SignerAddress is populated only on decode, so compare the encoded fields). + require.Equal(t, 0, compareHeader(decodedBatch.BatchHeader, batch.BatchHeader), "decoded batch header mismatch") + require.Equal(t, 0, compareTransaction(decodedBatch.L1InfoDeposit, batch.L1InfoDeposit), "decoded batch L1 info deposit mismatch") + require.Equal(t, batch.Batch.EpochNum, decodedBatch.Batch.EpochNum, "decoded batch epoch num mismatch") + require.Equal(t, batch.Batch.EpochHash, decodedBatch.Batch.EpochHash, "decoded batch epoch hash mismatch") + require.Equal(t, batch.Batch.Timestamp, decodedBatch.Batch.Timestamp, "decoded batch timestamp mismatch") + require.Equal(t, batch.Batch.Transactions, decodedBatch.Batch.Transactions, "decoded batch transactions mismatch") +} From b531ec92b1c4f24212803d1d18d301ad5545384c Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 15:45:31 -0400 Subject: [PATCH 08/13] verify batches while converting to a block --- op-node/rollup/derive/espresso_batch.go | 24 +++++++ op-node/rollup/derive/espresso_batch_test.go | 67 ++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/op-node/rollup/derive/espresso_batch.go b/op-node/rollup/derive/espresso_batch.go index fc22780bfd5..9f7b6cb3475 100644 --- a/op-node/rollup/derive/espresso_batch.go +++ b/op-node/rollup/derive/espresso_batch.go @@ -122,6 +122,30 @@ func UnmarshalEspressoTransaction(data []byte) (*EspressoBatch, error) { // invalid batches or in case of misconfiguration of the batcher, in which case it should fail // for all batches. func (b *EspressoBatch) ToBlock(rollupCfg *rollup.Config) (*types.Block, error) { + // The produced block must round-trip through BlockToSingularBatch when the channel + // manager encodes it, so enforce that function's requirements up front, plus + // consistency between the header and the batch body it claims to describe. + if b.BatchHeader == nil { + return nil, fmt.Errorf("batch has no header") + } + if b.L1InfoDeposit == nil || !b.L1InfoDeposit.IsDepositTx() { + return nil, fmt.Errorf("first transaction is not an L1 info deposit") + } + l1Info, err := L1BlockInfoFromBytes(rollupCfg, b.BatchHeader.Time, b.L1InfoDeposit.Data()) + if err != nil { + return nil, fmt.Errorf("could not parse the L1 info deposit: %w", err) + } + if b.Batch.ParentHash != b.BatchHeader.ParentHash { + return nil, fmt.Errorf("batch parent hash %s does not match header parent hash %s", b.Batch.ParentHash, b.BatchHeader.ParentHash) + } + if b.Batch.Timestamp != b.BatchHeader.Time { + return nil, fmt.Errorf("batch timestamp %d does not match header timestamp %d", b.Batch.Timestamp, b.BatchHeader.Time) + } + if uint64(b.Batch.EpochNum) != l1Info.Number || b.Batch.EpochHash != l1Info.BlockHash { + return nil, fmt.Errorf("batch epoch %d (%s) does not match L1 info deposit epoch %d (%s)", + b.Batch.EpochNum, b.Batch.EpochHash, l1Info.Number, l1Info.BlockHash) + } + // Re-insert the deposit transaction txs := []*types.Transaction{b.L1InfoDeposit} for i, opaqueTx := range b.Batch.Transactions { diff --git a/op-node/rollup/derive/espresso_batch_test.go b/op-node/rollup/derive/espresso_batch_test.go index a97fcc417f1..f960fc72770 100644 --- a/op-node/rollup/derive/espresso_batch_test.go +++ b/op-node/rollup/derive/espresso_batch_test.go @@ -287,3 +287,70 @@ func TestBatchRoundtrip(t *testing.T) { require.Equal(t, batch.Batch.Timestamp, decodedBatch.Batch.Timestamp, "decoded batch timestamp mismatch") require.Equal(t, batch.Batch.Transactions, decodedBatch.Batch.Transactions, "decoded batch transactions mismatch") } + +// TestToBlockRejectsMalformedBatch verifies that ToBlock rejects validly-structured but +// semantically malformed batches. +func TestToBlockRejectsMalformedBatch(t *testing.T) { + validBatch := func(t *testing.T) *derive.EspressoBatch { + block := dtest.RandomL2BlockWithChainIdAndTime( + rand.New(rand.NewSource(time.Now().Unix())), + 3, + defaultTestRollUpConfig.L2ChainID, + time.Now(), + ) + b, err := derive.BlockToEspressoBatch(defaultTestRollUpConfig, block) + require.NoError(t, err) + return b + } + + t.Run("valid batch converts", func(t *testing.T) { + b := validBatch(t) + _, err := b.ToBlock(defaultTestRollUpConfig) + require.NoError(t, err) + }) + + t.Run("nil L1 info deposit", func(t *testing.T) { + b := validBatch(t) + b.L1InfoDeposit = nil + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "not an L1 info deposit") + }) + + t.Run("first tx not a deposit", func(t *testing.T) { + b := validBatch(t) + // Replace the L1 info deposit with a decoded non-deposit tx from the batch body. + var nonDeposit gethTypes.Transaction + require.NoError(t, nonDeposit.UnmarshalBinary(b.Batch.Transactions[0])) + b.L1InfoDeposit = &nonDeposit + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "not an L1 info deposit") + }) + + t.Run("malformed L1 info data", func(t *testing.T) { + b := validBatch(t) + b.L1InfoDeposit = gethTypes.NewTx(&gethTypes.DepositTx{Data: []byte{0xde, 0xad, 0xbe, 0xef}}) + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "could not parse the L1 info deposit") + }) + + t.Run("parent hash mismatch", func(t *testing.T) { + b := validBatch(t) + b.Batch.ParentHash[0] ^= 0xff + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "parent hash") + }) + + t.Run("timestamp mismatch", func(t *testing.T) { + b := validBatch(t) + b.Batch.Timestamp++ + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "timestamp") + }) + + t.Run("epoch mismatch", func(t *testing.T) { + b := validBatch(t) + b.Batch.EpochNum++ + _, err := b.ToBlock(defaultTestRollUpConfig) + require.ErrorContains(t, err, "epoch") + }) +} From 62e97b8a4a0947a12c24019e78655be2a18608c7 Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 15:49:09 -0400 Subject: [PATCH 09/13] start throttling loop after espresso/non espresso loops --- op-batcher/batcher/driver.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index bca2442e710..24275acbae0 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -224,14 +224,6 @@ func (l *BatchSubmitter) StartBatchSubmitting() error { publishSignal := make(chan pubInfo, 1) l.publishSignal = publishSignal - // DA throttling loop should always be started except for testing (indicated by ThrottleThreshold == 0) - if l.Config.ThrottleParams.LowerThreshold > 0 { - l.wg.Add(1) - go l.throttlingLoop(l.wg, unsafeBytesUpdated) // ranges over unsafeBytesUpdated channel - } else { - l.Log.Warn("Throttling loop is DISABLED due to 0 throttle-threshold. This should not be disabled in prod.") - } - if l.Config.Espresso.Enabled { if err := l.startEspressoLoops(receiptsCh, publishSignal); err != nil { return err @@ -243,6 +235,14 @@ func (l *BatchSubmitter) StartBatchSubmitting() error { go l.blockLoadingLoop(l.shutdownCtx, l.wg, unsafeBytesUpdated, publishSignal) // sends on unsafeBytesUpdated (if throttling enabled), and publishSignal. Closes them both when done } + // DA throttling loop should always be started except for testing (indicated by ThrottleThreshold == 0) + if l.Config.ThrottleParams.LowerThreshold > 0 { + l.wg.Add(1) + go l.throttlingLoop(l.wg, unsafeBytesUpdated) // ranges over unsafeBytesUpdated channel + } else { + l.Log.Warn("Throttling loop is DISABLED due to 0 throttle-threshold. This should not be disabled in prod.") + } + l.Log.Info("Batch Submitter started") return nil } From abb7ee1e5f424c52d1ac59da25e786dd80144378 Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 16:01:53 -0400 Subject: [PATCH 10/13] make use of unsafeBytesUpdated --- op-batcher/batcher/driver.go | 2 +- op-batcher/batcher/espresso.go | 9 +++++++-- op-batcher/batcher/espresso_driver.go | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 24275acbae0..18907a7ed64 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -225,7 +225,7 @@ func (l *BatchSubmitter) StartBatchSubmitting() error { l.publishSignal = publishSignal if l.Config.Espresso.Enabled { - if err := l.startEspressoLoops(receiptsCh, publishSignal); err != nil { + if err := l.startEspressoLoops(receiptsCh, publishSignal, unsafeBytesUpdated); err != nil { return err } } else { diff --git a/op-batcher/batcher/espresso.go b/op-batcher/batcher/espresso.go index 00d19f15526..17ba19751a8 100644 --- a/op-batcher/batcher/espresso.go +++ b/op-batcher/batcher/espresso.go @@ -908,14 +908,17 @@ func (l *BatchSubmitter) peekNextBatch(ctx context.Context, syncStatus *eth.Sync return batch } -// Periodically refreshes the sync status and polls Espresso streamer for new batches -func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync.WaitGroup, publishSignal chan pubInfo) { +// Periodically refreshes the sync status and polls Espresso streamer for new batches. +// Owns publishSignal and unsafeBytesUpdated: it is their only closer, so the loops +// ranging over them (publishingLoop, throttlingLoop) terminate when this loop exits. +func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync.WaitGroup, publishSignal chan pubInfo, unsafeBytesUpdated chan int64) { l.Log.Info("Starting EspressoBatchLoadingLoop", "polling interval", l.Config.Espresso.PollInterval) defer wg.Done() ticker := time.NewTicker(l.Config.Espresso.PollInterval) defer ticker.Stop() defer close(publishSignal) + defer close(unsafeBytesUpdated) for { select { @@ -970,6 +973,8 @@ func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync. l.EspressoStreamer().Next(ctx) l.Log.Info("Added L2 block to channel manager", "blockNr", block.NumberU64()) + // We have increased the unsafe data. Signal the throttling loop to + l.sendToThrottlingLoop(unsafeBytesUpdated) } l.tryPublishSignal(publishSignal, pubInfo{}) diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 1f2fc09aed3..28b582376f4 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -109,7 +109,7 @@ func (l *BatchSubmitter) setupEspressoStreamer() { // submitter, and starts the four Espresso-specific batcher goroutines (in // addition to the upstream receiptsLoop and publishingLoop). Replaces the // upstream three-goroutine pattern when --espresso.enabled is set. -func (l *BatchSubmitter) startEspressoLoops(receiptsCh chan txmgr.TxReceipt[txRef], publishSignal chan pubInfo) error { +func (l *BatchSubmitter) startEspressoLoops(receiptsCh chan txmgr.TxReceipt[txRef], publishSignal chan pubInfo, unsafeBytesUpdated chan int64) error { if err := l.registerBatcher(l.killCtx); err != nil { return fmt.Errorf("could not register with BatchAuthenticator contract: %w", err) } @@ -133,8 +133,8 @@ func (l *BatchSubmitter) startEspressoLoops(receiptsCh chan txmgr.TxReceipt[txRe l.wg.Add(4) go l.receiptsLoop(l.wg, receiptsCh) // ranges over receiptsCh channel go l.espressoBatchQueueingLoop(l.shutdownCtx, l.wg) - go l.espressoBatchLoadingLoop(l.shutdownCtx, l.wg, publishSignal) - go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done. + go l.espressoBatchLoadingLoop(l.shutdownCtx, l.wg, publishSignal, unsafeBytesUpdated) // sends on unsafeBytesUpdated (if throttling enabled) and publishSignal. Closes them both when done + go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done. return nil } From 700f54904e4220f3fe425cfe83210ff391567c9f Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 19:15:12 -0400 Subject: [PATCH 11/13] initChainSigner fallback for non espresso mode --- op-batcher/batcher/espresso_service.go | 5 ++++- op-batcher/batcher/service.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/op-batcher/batcher/espresso_service.go b/op-batcher/batcher/espresso_service.go index 4217abd8af9..a6f86b151e9 100644 --- a/op-batcher/batcher/espresso_service.go +++ b/op-batcher/batcher/espresso_service.go @@ -62,7 +62,10 @@ func (bs *BatcherService) EspressoStreamer() espresso.EspressoStreamer[derive.Es // ChainSigner interface and stores the embedded ChainSigner on the service. // Espresso uses ChainSigner to sign batch authentication payloads sent to the // BatchAuthenticator contract; the cast is required by every Espresso path. -func (bs *BatcherService) initChainSigner() error { +func (bs *BatcherService) initChainSigner(cfg *CLIConfig) error { + if !cfg.Espresso.Enabled { + return nil + } cast, castOk := bs.TxManager.(opcrypto.ChainSigner) if !castOk { return fmt.Errorf("tx manager does not implement ChainSigner") diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 7db4d6866e3..69a620d2c18 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -386,7 +386,7 @@ func (bs *BatcherService) initTxManager(_ context.Context, cfg *CLIConfig) error return err } bs.TxManager = txManager - if err := bs.initChainSigner(); err != nil { + if err := bs.initChainSigner(cfg); err != nil { return err } return nil From fbbcd187167f6d5bdc0c892d425138617ccf892c Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 20:02:15 -0400 Subject: [PATCH 12/13] lint and make use of isBatcherActive --- op-batcher/batcher/driver.go | 4 +++ op-batcher/batcher/espresso.go | 13 +++---- op-batcher/batcher/espresso_driver.go | 35 ++++++++++++++++++- op-batcher/batcher/espresso_service.go | 3 +- .../espresso_transaction_submitter_test.go | 4 ++- op-node/rollup/derive/espresso_batch.go | 3 +- op-node/rollup/derive/espresso_batch_test.go | 3 +- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 18907a7ed64..45b5ad9b67a 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -858,6 +858,10 @@ func (l *BatchSubmitter) publishStateToL1(ctx context.Context, queue *txmgr.Queu return } + if l.shouldSkipPublishForActiveSeq(ctx) { + return + } + err := l.publishTxToL1(ctx, queue, receiptsCh, daGroup, pi) if err != nil { if err != io.EOF { diff --git a/op-batcher/batcher/espresso.go b/op-batcher/batcher/espresso.go index 17ba19751a8..7052bb0d202 100644 --- a/op-batcher/batcher/espresso.go +++ b/op-batcher/batcher/espresso.go @@ -29,6 +29,7 @@ import ( "github.com/ethereum-optimism/optimism/espresso" "github.com/ethereum-optimism/optimism/espresso/bindings" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/bigs" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) @@ -815,7 +816,7 @@ func (l *BatchSubmitter) queueBlockToEspresso(ctx context.Context, block *types. return fmt.Errorf("failed to derive batch from block: %w", err) } - transaction, err := espressoBatch.ToEspressoTransaction(ctx, l.RollupConfig.L2ChainID.Uint64(), l.Espresso.ChainSigner) + transaction, err := espressoBatch.ToEspressoTransaction(ctx, bigs.Uint64Strict(l.RollupConfig.L2ChainID), l.Espresso.ChainSigner) if err != nil { l.Log.Warn("Failed to create Espresso transaction from a batch", "err", err) return fmt.Errorf("failed to create Espresso transaction from a batch: %w", err) @@ -823,7 +824,7 @@ func (l *BatchSubmitter) queueBlockToEspresso(ctx context.Context, block *types. commitment := transaction.Commit() hash, _ := tagged_base64.New("TX", commitment[:]) - l.Log.Info("Created Espresso transaction from batch", "hash", hash, "batchNr", espressoBatch.BatchHeader.Number.Uint64()) + l.Log.Info("Created Espresso transaction from batch", "hash", hash, "batchNr", bigs.Uint64Strict(espressoBatch.BatchHeader.Number)) if err := l.espressoSubmitter.SubmitTransaction(transaction); err != nil { return fmt.Errorf("failed to submit job to espresso: %w", err) @@ -1186,12 +1187,12 @@ func (l *BatchSubmitter) espressoBatchQueueingLoop(ctx context.Context, wg *sync // This is a check to help us determine whether we're able to // push through all of the blocks we've attempted to or not. - if (numEnqueuedBlocksAfter - numEnqueuedBlocksBefore) < int(blocksToQueue.numBlocks()) { - // If we're in this conditional, it means we weren't able - // submit all of the blocks to Espresso that we were - // attempting to. + if enqueued := numEnqueuedBlocksAfter - numEnqueuedBlocksBefore; enqueued < int(blocksToQueue.numBlocks()) { + // We weren't able to submit all of the blocks to Espresso + // that we were attempting to. // // TODO: We should probably throttle a bit. + l.Log.Debug("Could not enqueue all blocks to Espresso", "enqueued", enqueued, "attempted", blocksToQueue.numBlocks()) } } else if action == ActionReset { loader.reset(ctx) diff --git a/op-batcher/batcher/espresso_driver.go b/op-batcher/batcher/espresso_driver.go index 28b582376f4..5c79b413f7e 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum-optimism/optimism/espresso" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/bigs" opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/txmgr" ) @@ -85,7 +86,7 @@ func (l *BatchSubmitter) setupEspressoStreamer() { lightClientIface = l.Espresso.LightClient } unbufferedStreamer, err := op.NewEspressoStreamer( - l.RollupConfig.L2ChainID.Uint64(), + bigs.Uint64Strict(l.RollupConfig.L2ChainID), l1Adapter, l1Adapter, l.Espresso.Client, @@ -138,6 +139,38 @@ func (l *BatchSubmitter) startEspressoLoops(receiptsCh chan txmgr.TxReceipt[txRe return nil } +// shouldSkipPublishForActiveSeq returns true if publishStateToL1 should skip +// publishing because this batcher is not the on-chain "active" batcher +// (BatchAuthenticator.activeIsEspresso). The Espresso TEE batcher always +// honors the flag (it is fundamentally a post-fork actor); the fallback +// batcher honors it only once fallback auth is required (pre-fork it must run +// as a vanilla upstream Optimism batcher with no BatchAuthenticator coupling). +// Fails closed: if either gate cannot be evaluated, publishing is skipped for +// this tick and retried on the next. +func (l *BatchSubmitter) shouldSkipPublishForActiveSeq(ctx context.Context) bool { + if l.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { + return false + } + consultActiveFlag := l.Config.Espresso.Enabled + if !consultActiveFlag { + fallbackAuthRequired, err := l.isFallbackAuthRequired(ctx) + if err != nil { + l.Log.Warn("Failed to evaluate fallback-auth gate, skipping publish", "err", err) + return true + } + consultActiveFlag = fallbackAuthRequired + } + if !consultActiveFlag { + return false + } + isActive, err := l.isBatcherActive(ctx) + if err != nil { + l.Log.Warn("Failed to check if batcher is active, skipping publish", "err", err) + return true + } + return !isActive +} + // resetEspressoStreamer resets the Espresso streamer when --espresso.enabled // is set; no-op otherwise. Called from clearState alongside the upstream // channel-manager reset so the streamer's view of "next batch" matches the diff --git a/op-batcher/batcher/espresso_service.go b/op-batcher/batcher/espresso_service.go index a6f86b151e9..bc016244c2d 100644 --- a/op-batcher/batcher/espresso_service.go +++ b/op-batcher/batcher/espresso_service.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum-optimism/optimism/espresso" "github.com/ethereum-optimism/optimism/op-batcher/enclave" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/bigs" opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto" ) @@ -126,7 +127,7 @@ func (bs *BatcherService) initEspresso(cfg *CLIConfig) error { } if cfg.Espresso.Namespace == 0 { log.Info("Using L2 chain ID as namespace by default") - cfg.Espresso.Namespace = bs.RollupConfig.L2ChainID.Uint64() + cfg.Espresso.Namespace = bigs.Uint64Strict(bs.RollupConfig.L2ChainID) } if cfg.Espresso.BatchAuthenticatorAddr == (common.Address{}) { cfg.Espresso.BatchAuthenticatorAddr = bs.RollupConfig.BatchAuthenticatorAddress diff --git a/op-batcher/batcher/espresso_transaction_submitter_test.go b/op-batcher/batcher/espresso_transaction_submitter_test.go index 6c736bdbe3b..1e65887ee74 100644 --- a/op-batcher/batcher/espresso_transaction_submitter_test.go +++ b/op-batcher/batcher/espresso_transaction_submitter_test.go @@ -58,7 +58,9 @@ func TestEspressoTransactionSubmitterDeadlock(t *testing.T) { submitCtx, submitCancel := context.WithCancel(context.Background()) go (func(cancel context.CancelFunc, txn espressoCommon.Transaction) { - submitter.SubmitTransaction(&txn) + // The error is irrelevant here: the test only observes whether the + // call returns (via cancel) to detect a deadlock. + _ = submitter.SubmitTransaction(&txn) cancel() })(submitCancel, txn) diff --git a/op-node/rollup/derive/espresso_batch.go b/op-node/rollup/derive/espresso_batch.go index 9f7b6cb3475..74d5b447310 100644 --- a/op-node/rollup/derive/espresso_batch.go +++ b/op-node/rollup/derive/espresso_batch.go @@ -7,6 +7,7 @@ import ( espressoCommon "github.com/EspressoSystems/espresso-network/sdks/go/types" "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/bigs" opCrypto "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum/go-ethereum/common" @@ -25,7 +26,7 @@ type EspressoBatch struct { } func (b EspressoBatch) Number() uint64 { - return b.BatchHeader.Number.Uint64() + return bigs.Uint64Strict(b.BatchHeader.Number) } func (b EspressoBatch) L1Origin() eth.BlockID { diff --git a/op-node/rollup/derive/espresso_batch_test.go b/op-node/rollup/derive/espresso_batch_test.go index f960fc72770..bed6e4856a5 100644 --- a/op-node/rollup/derive/espresso_batch_test.go +++ b/op-node/rollup/derive/espresso_batch_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup" derive "github.com/ethereum-optimism/optimism/op-node/rollup/derive" dtest "github.com/ethereum-optimism/optimism/op-node/rollup/derive/test" + "github.com/ethereum-optimism/optimism/op-service/bigs" "github.com/ethereum-optimism/optimism/op-service/crypto" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/signer" @@ -267,7 +268,7 @@ func TestBatchRoundtrip(t *testing.T) { transaction, err := batch.ToEspressoTransaction( context.Background(), - defaultTestRollUpConfig.L2ChainID.Uint64(), + bigs.Uint64Strict(defaultTestRollUpConfig.L2ChainID), chainSigner, ) require.NoError(t, err, "failed to serialize batch to Espresso transaction") From 014f88df8ac1adf219b3012e902c0614773cfe29 Mon Sep 17 00:00:00 2001 From: Luke Iannucci Date: Mon, 6 Jul 2026 20:30:33 -0400 Subject: [PATCH 13/13] request to clear state from BlockLoader --- op-batcher/batcher/driver.go | 4 ++++ op-batcher/batcher/espresso.go | 32 ++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/op-batcher/batcher/driver.go b/op-batcher/batcher/driver.go index 45b5ad9b67a..91f70c0d88d 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -8,6 +8,7 @@ import ( "math/big" _ "net/http/pprof" "sync" + "sync/atomic" "time" "golang.org/x/sync/errgroup" @@ -158,6 +159,9 @@ type BatchSubmitter struct { espressoSubmitter *espressoTransactionSubmitter espressoStreamer espresso.EspressoStreamer[derive.EspressoBatch] + // clearStateRequested asks the espresso batch loading loop to run clearState + clearStateRequested atomic.Bool + teeVerifierAddress common.Address // degradedLog throttles repeated warnings from tick-driven loops so the diff --git a/op-batcher/batcher/espresso.go b/op-batcher/batcher/espresso.go index 7052bb0d202..3c021b81085 100644 --- a/op-batcher/batcher/espresso.go +++ b/op-batcher/batcher/espresso.go @@ -909,6 +909,22 @@ func (l *BatchSubmitter) peekNextBatch(ctx context.Context, syncStatus *eth.Sync return batch } +// requestClearState asks the batch loading loop to perform `l.clearState`. +func (l *BatchSubmitter) requestClearState() { + l.clearStateRequested.Store(true) +} + +// performClearState runs clearState if it was requested via requestClearState, +// reporting whether a clear was performed. +func (l *BatchSubmitter) performClearState(ctx context.Context) bool { + if !l.clearStateRequested.CompareAndSwap(true, false) { + return false + } + l.Log.Info("Clearing state as requested by the block queueing loop") + l.clearState(ctx) + return true +} + // Periodically refreshes the sync status and polls Espresso streamer for new batches. // Owns publishSignal and unsafeBytesUpdated: it is their only closer, so the loops // ranging over them (publishingLoop, throttlingLoop) terminate when this loop exits. @@ -924,6 +940,9 @@ func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync. for { select { case <-ticker.C: + // Check if block loader requested to clear state + l.performClearState(ctx) + newSyncStatus, err := l.getSyncStatus(ctx) if err != nil { l.degradedLog.Warn(l.Log, "syncStatusErr/espressoBatchLoading", "failed to refresh sync status", "err", err) @@ -938,6 +957,10 @@ func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync. var batch *derive.EspressoBatch for { + // Check if block loader requested to clear state + if l.performClearState(ctx) { + break + } batch = l.peekNextBatch(ctx, newSyncStatus) @@ -975,6 +998,7 @@ func (l *BatchSubmitter) espressoBatchLoadingLoop(ctx context.Context, wg *sync. l.EspressoStreamer().Next(ctx) l.Log.Info("Added L2 block to channel manager", "blockNr", block.NumberU64()) // We have increased the unsafe data. Signal the throttling loop to + // check if it should throttle. l.sendToThrottlingLoop(unsafeBytesUpdated) } @@ -1000,10 +1024,10 @@ type BlockLoader struct { batcher *BatchSubmitter } -func (l *BlockLoader) reset(ctx context.Context) { +func (l *BlockLoader) reset() { l.prevSyncStatus = nil l.queuedBlocks = nil - l.batcher.clearState(ctx) + l.batcher.requestClearState() } func (l *BlockLoader) EnqueueBlocks(ctx context.Context, blocksToQueue inclusiveBlockRange) { @@ -1022,7 +1046,7 @@ func (l *BlockLoader) EnqueueBlocks(ctx context.Context, blocksToQueue inclusive if len(l.queuedBlocks) > 0 && block.ParentHash() != l.queuedBlocks[len(l.queuedBlocks)-1].Hash { l.batcher.Log.Warn("Found L2 reorg", "block_number", i) - l.reset(ctx) + l.reset() break } @@ -1195,7 +1219,7 @@ func (l *BatchSubmitter) espressoBatchQueueingLoop(ctx context.Context, wg *sync l.Log.Debug("Could not enqueue all blocks to Espresso", "enqueued", enqueued, "attempted", blocksToQueue.numBlocks()) } } else if action == ActionReset { - loader.reset(ctx) + loader.reset() } case <-ctx.Done():