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 a51b969a148..a98c2518a8d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ 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 @@ -21,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 @@ -32,6 +34,8 @@ 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 github.com/ipfs/go-ds-leveldb v0.5.0 @@ -69,7 +73,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 @@ -77,10 +84,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 ) @@ -113,7 +122,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 e3f1c2994ab..6e5dca219ba 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,10 @@ 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/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= @@ -171,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= @@ -256,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= @@ -419,6 +425,10 @@ 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= 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= @@ -823,6 +833,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= @@ -881,6 +893,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= @@ -956,6 +970,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= @@ -1127,6 +1142,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/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..91f70c0d88d 100644 --- a/op-batcher/batcher/driver.go +++ b/op-batcher/batcher/driver.go @@ -8,10 +8,12 @@ import ( "math/big" _ "net/http/pprof" "sync" + "sync/atomic" "time" "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 +22,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 +31,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 +89,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 +117,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 +147,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 +155,18 @@ 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] + + // 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 + // 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 +179,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 +187,8 @@ func NewBatchSubmitter(setup DriverSetup) *BatchSubmitter { panic(err) } + batcher.setupEspressoStreamer() + return batcher } @@ -203,6 +228,17 @@ func (l *BatchSubmitter) StartBatchSubmitting() error { publishSignal := make(chan pubInfo, 1) l.publishSignal = publishSignal + if l.Config.Espresso.Enabled { + if err := l.startEspressoLoops(receiptsCh, publishSignal, unsafeBytesUpdated); 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 + } + // DA throttling loop should always be started except for testing (indicated by ThrottleThreshold == 0) if l.Config.ThrottleParams.LowerThreshold > 0 { l.wg.Add(1) @@ -211,11 +247,6 @@ 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 - l.Log.Info("Batch Submitter started") return nil } @@ -831,6 +862,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 { @@ -856,6 +891,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..3c021b81085 --- /dev/null +++ b/op-batcher/batcher/espresso.go @@ -0,0 +1,1477 @@ +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/bigs" + "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, 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) + } + + commitment := transaction.Commit() + hash, _ := tagged_base64.New("TX", commitment[:]) + 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) + } + + 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 +} + +// 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. +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 { + 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) + 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 { + // Check if block loader requested to clear state + if l.performClearState(ctx) { + break + } + + 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()) + // We have increased the unsafe data. Signal the throttling loop to + // check if it should throttle. + l.sendToThrottlingLoop(unsafeBytesUpdated) + } + + 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() { + l.prevSyncStatus = nil + l.queuedBlocks = nil + l.batcher.requestClearState() +} + +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() + 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 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() + } + + 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 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, daType: txdata.daType, size: txdata.Len()} + 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.submitAuthenticatedBatch(transactionReference, verifyCandidate, candidate, queue, receiptsCh) +} + +// 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/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 a2816c64c93..5c79b413f7e 100644 --- a/op-batcher/batcher/espresso_driver.go +++ b/op-batcher/batcher/espresso_driver.go @@ -1,13 +1,25 @@ 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" + "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" ) -// 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 +28,179 @@ 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). // -// The fallback batcher consults isFallbackAuthRequired to gate authentication +// 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( + bigs.Uint64Strict(l.RollupConfig.L2ChainID), + 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, unsafeBytesUpdated chan int64) 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, 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 +} + +// 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 +// 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 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.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..bc016244c2d --- /dev/null +++ b/op-batcher/batcher/espresso_service.go @@ -0,0 +1,195 @@ +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" + "github.com/ethereum-optimism/optimism/op-service/bigs" + 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(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") + } + 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 = bigs.Uint64Strict(bs.RollupConfig.L2ChainID) + } + 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..1e65887ee74 --- /dev/null +++ b/op-batcher/batcher/espresso_transaction_submitter_test.go @@ -0,0 +1,179 @@ +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) { + // 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) + + 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/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)) - } - }) -} diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 72ad9806b18..69a620d2c18 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(cfg); 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/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) +} 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-node/rollup/derive/espresso_batch.go b/op-node/rollup/derive/espresso_batch.go new file mode 100644 index 00000000000..74d5b447310 --- /dev/null +++ b/op-node/rollup/derive/espresso_batch.go @@ -0,0 +1,163 @@ +package derive + +import ( + "bytes" + "context" + "fmt" + + 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" + "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 bigs.Uint64Strict(b.BatchHeader.Number) +} + +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) { + // 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 { + 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..bed6e4856a5 --- /dev/null +++ b/op-node/rollup/derive/espresso_batch_test.go @@ -0,0 +1,357 @@ +package derive_test + +import ( + "bytes" + "context" + "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/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" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + 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), +} + +// 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, gethCrypto.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.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) + } +} + +// 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(), + bigs.Uint64Strict(defaultTestRollUpConfig.L2ChainID), + 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") +} + +// 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") + }) +} 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/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)) +} 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 +}