diff --git a/op-program/client/l1/client.go b/op-program/client/l1/client.go new file mode 100644 index 0000000000000..e23b34995a0d5 --- /dev/null +++ b/op-program/client/l1/client.go @@ -0,0 +1,69 @@ +package l1 + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" +) + +var ( + ErrNotFound = ethereum.NotFound + ErrUnknownLabel = errors.New("unknown label") +) + +type OracleL1Client struct { + oracle Oracle + head eth.L1BlockRef +} + +func NewOracleL1Client(logger log.Logger, oracle Oracle, l1Head common.Hash) *OracleL1Client { + head := eth.InfoToL1BlockRef(oracle.HeaderByBlockHash(l1Head)) + logger.Info("L1 head loaded", "hash", head.Hash, "number", head.Number) + return &OracleL1Client{ + oracle: oracle, + head: head, + } +} + +func (o OracleL1Client) L1BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L1BlockRef, error) { + if label != eth.Unsafe && label != eth.Safe && label != eth.Finalized { + return eth.L1BlockRef{}, fmt.Errorf("%w: %s", ErrUnknownLabel, label) + } + // The L1 head is pre-agreed and unchanging so it can be used for all of unsafe, safe and finalized + return o.head, nil +} + +func (o OracleL1Client) L1BlockRefByNumber(ctx context.Context, number uint64) (eth.L1BlockRef, error) { + if number > o.head.Number { + return eth.L1BlockRef{}, fmt.Errorf("%w: block number %d", ErrNotFound, number) + } + block := o.head + for block.Number > number { + block = eth.InfoToL1BlockRef(o.oracle.HeaderByBlockHash(block.ParentHash)) + } + return block, nil +} + +func (o OracleL1Client) L1BlockRefByHash(ctx context.Context, hash common.Hash) (eth.L1BlockRef, error) { + return eth.InfoToL1BlockRef(o.oracle.HeaderByBlockHash(hash)), nil +} + +func (o OracleL1Client) InfoByHash(ctx context.Context, hash common.Hash) (eth.BlockInfo, error) { + return o.oracle.HeaderByBlockHash(hash), nil +} + +func (o OracleL1Client) FetchReceipts(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, types.Receipts, error) { + info, rcpts := o.oracle.ReceiptsByBlockHash(blockHash) + return info, rcpts, nil +} + +func (o OracleL1Client) InfoAndTxsByHash(ctx context.Context, hash common.Hash) (eth.BlockInfo, types.Transactions, error) { + info, txs := o.oracle.TransactionsByBlockHash(hash) + return info, txs, nil +} diff --git a/op-program/client/l1/client_test.go b/op-program/client/l1/client_test.go new file mode 100644 index 0000000000000..0a3b67e6e409c --- /dev/null +++ b/op-program/client/l1/client_test.go @@ -0,0 +1,210 @@ +package l1 + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-node/sources" + "github.com/ethereum-optimism/optimism/op-node/testlog" + "github.com/ethereum-optimism/optimism/op-node/testutils" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +var _ derive.L1Fetcher = (*OracleL1Client)(nil) + +var head = blockNum(1000) + +func TestInfoByHash(t *testing.T) { + client, oracle := newClient(t) + hash := common.HexToHash("0xAABBCC") + expected := &sources.HeaderInfo{} + oracle.blocks[hash] = expected + + info, err := client.InfoByHash(context.Background(), hash) + require.NoError(t, err) + require.Equal(t, expected, info) +} + +func TestL1BlockRefByHash(t *testing.T) { + client, oracle := newClient(t) + hash := common.HexToHash("0xAABBCC") + header := &sources.HeaderInfo{} + oracle.blocks[hash] = header + expected := eth.InfoToL1BlockRef(header) + + ref, err := client.L1BlockRefByHash(context.Background(), hash) + require.NoError(t, err) + require.Equal(t, expected, ref) +} + +func TestFetchReceipts(t *testing.T) { + client, oracle := newClient(t) + hash := common.HexToHash("0xAABBCC") + expectedInfo := &sources.HeaderInfo{} + expectedReceipts := types.Receipts{ + &types.Receipt{}, + } + oracle.blocks[hash] = expectedInfo + oracle.rcpts[hash] = expectedReceipts + + info, rcpts, err := client.FetchReceipts(context.Background(), hash) + require.NoError(t, err) + require.Equal(t, expectedInfo, info) + require.Equal(t, expectedReceipts, rcpts) +} + +func TestInfoAndTxsByHash(t *testing.T) { + client, oracle := newClient(t) + hash := common.HexToHash("0xAABBCC") + expectedInfo := &sources.HeaderInfo{} + expectedTxs := types.Transactions{ + &types.Transaction{}, + } + oracle.blocks[hash] = expectedInfo + oracle.txs[hash] = expectedTxs + + info, txs, err := client.InfoAndTxsByHash(context.Background(), hash) + require.NoError(t, err) + require.Equal(t, expectedInfo, info) + require.Equal(t, expectedTxs, txs) +} + +func TestL1BlockRefByLabel(t *testing.T) { + t.Run("Unsafe", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByLabel(context.Background(), eth.Unsafe) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(head), ref) + }) + t.Run("Safe", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByLabel(context.Background(), eth.Safe) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(head), ref) + }) + t.Run("Finalized", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByLabel(context.Background(), eth.Finalized) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(head), ref) + }) + t.Run("UnknownLabel", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByLabel(context.Background(), eth.BlockLabel("unknown")) + require.ErrorIs(t, err, ErrUnknownLabel) + require.Equal(t, eth.L1BlockRef{}, ref) + }) +} + +func TestL1BlockRefByNumber(t *testing.T) { + t.Run("Head", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByNumber(context.Background(), head.NumberU64()) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(head), ref) + }) + t.Run("AfterHead", func(t *testing.T) { + client, _ := newClient(t) + ref, err := client.L1BlockRefByNumber(context.Background(), head.NumberU64()+1) + // Must be ethereum.NotFound error so the derivation pipeline knows it has gone past the chain head + require.ErrorIs(t, err, ethereum.NotFound) + require.Equal(t, eth.L1BlockRef{}, ref) + }) + t.Run("ParentOfHead", func(t *testing.T) { + client, oracle := newClient(t) + parent := blockNum(head.NumberU64() - 1) + oracle.blocks[parent.Hash()] = parent + + ref, err := client.L1BlockRefByNumber(context.Background(), parent.NumberU64()) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(parent), ref) + }) + t.Run("AncestorOfHead", func(t *testing.T) { + client, oracle := newClient(t) + block := head + blocks := []eth.BlockInfo{block} + for i := 0; i < 10; i++ { + block = blockNum(block.NumberU64() - 1) + oracle.blocks[block.Hash()] = block + blocks = append(blocks, block) + } + + for _, block := range blocks { + ref, err := client.L1BlockRefByNumber(context.Background(), block.NumberU64()) + require.NoError(t, err) + require.Equal(t, eth.InfoToL1BlockRef(block), ref) + } + }) +} + +func newClient(t *testing.T) (*OracleL1Client, *stubOracle) { + stub := &stubOracle{ + t: t, + blocks: make(map[common.Hash]eth.BlockInfo), + txs: make(map[common.Hash]types.Transactions), + rcpts: make(map[common.Hash]types.Receipts), + } + stub.blocks[head.Hash()] = head + client := NewOracleL1Client(testlog.Logger(t, log.LvlDebug), stub, head.Hash()) + return client, stub +} + +type stubOracle struct { + t *testing.T + + // blocks maps block hash to eth.BlockInfo + blocks map[common.Hash]eth.BlockInfo + + // txs maps block hash to transactions + txs map[common.Hash]types.Transactions + + // rcpts maps Block hash to receipts + rcpts map[common.Hash]types.Receipts +} + +func (o stubOracle) HeaderByBlockHash(blockHash common.Hash) eth.BlockInfo { + info, ok := o.blocks[blockHash] + if !ok { + o.t.Fatalf("unknown block %s", blockHash) + } + return info +} + +func (o stubOracle) TransactionsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Transactions) { + txs, ok := o.txs[blockHash] + if !ok { + o.t.Fatalf("unknown txs %s", blockHash) + } + return o.HeaderByBlockHash(blockHash), txs +} + +func (o stubOracle) ReceiptsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Receipts) { + rcpts, ok := o.rcpts[blockHash] + if !ok { + o.t.Fatalf("unknown rcpts %s", blockHash) + } + return o.HeaderByBlockHash(blockHash), rcpts +} + +func blockNum(num uint64) eth.BlockInfo { + parentNum := num - 1 + return &testutils.MockBlockInfo{ + InfoHash: common.BytesToHash(big.NewInt(int64(num)).Bytes()), + InfoParentHash: common.BytesToHash(big.NewInt(int64(parentNum)).Bytes()), + InfoCoinbase: common.Address{}, + InfoRoot: common.Hash{}, + InfoNum: num, + InfoTime: num * 2, + InfoMixDigest: [32]byte{}, + InfoBaseFee: nil, + InfoReceiptRoot: common.Hash{}, + InfoGasUsed: 0, + } +} diff --git a/op-program/client/l1/oracle.go b/op-program/client/l1/oracle.go new file mode 100644 index 0000000000000..0ceda9c6b1b15 --- /dev/null +++ b/op-program/client/l1/oracle.go @@ -0,0 +1,18 @@ +package l1 + +import ( + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type Oracle interface { + // HeaderByBlockHash retrieves the block header with the given hash. + HeaderByBlockHash(blockHash common.Hash) eth.BlockInfo + + // TransactionsByBlockHash retrieves the transactions from the block with the given hash. + TransactionsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Transactions) + + // ReceiptsByBlockHash retrieves the receipts from the block with the given hash. + ReceiptsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Receipts) +} diff --git a/op-program/client/l2/engine_backend.go b/op-program/client/l2/engine_backend.go index 3a84f126a0c37..7fd4c741a468e 100644 --- a/op-program/client/l2/engine_backend.go +++ b/op-program/client/l2/engine_backend.go @@ -39,6 +39,7 @@ func NewOracleBackedL2Chain(logger log.Logger, oracle Oracle, chainCfg *params.C if err != nil { return nil, fmt.Errorf("loading l2 head: %w", err) } + logger.Info("Loaded L2 head", "hash", head.Hash(), "number", head.Number()) return &OracleBackedL2Chain{ log: logger, oracle: oracle, diff --git a/op-program/host/cmd/main_test.go b/op-program/host/cmd/main_test.go index f411fd2541461..7dcb04654779c 100644 --- a/op-program/host/cmd/main_test.go +++ b/op-program/host/cmd/main_test.go @@ -13,7 +13,9 @@ import ( "github.com/stretchr/testify/require" ) -var l2HeadValue = "0x6303578b1fa9480389c51bbcef6fe045bb877da39740819e9eb5f36f94949bd0" +// Use HexToHash(...).Hex() to ensure the strings are the correct length for a hash +var l1HeadValue = common.HexToHash("0x111111").Hex() +var l2HeadValue = common.HexToHash("0x222222").Hex() func TestLogLevel(t *testing.T) { t.Run("RejectInvalid", func(t *testing.T) { @@ -32,7 +34,8 @@ func TestLogLevel(t *testing.T) { func TestDefaultCLIOptionsMatchDefaultConfig(t *testing.T) { cfg := configForArgs(t, addRequiredArgs()) - require.Equal(t, config.NewConfig(&chaincfg.Goerli, "genesis.json", common.HexToHash(l2HeadValue)), cfg) + defaultCfg := config.NewConfig(&chaincfg.Goerli, "genesis.json", common.HexToHash(l1HeadValue), common.HexToHash(l2HeadValue)) + require.Equal(t, defaultCfg, cfg) } func TestNetwork(t *testing.T) { @@ -102,6 +105,21 @@ func TestL2Head(t *testing.T) { }) } +func TestL1Head(t *testing.T) { + t.Run("Required", func(t *testing.T) { + verifyArgsInvalid(t, "flag l1.head is required", addRequiredArgsExcept("--l1.head")) + }) + + t.Run("Valid", func(t *testing.T) { + cfg := configForArgs(t, replaceRequiredArg("--l1.head", l1HeadValue)) + require.Equal(t, common.HexToHash(l1HeadValue), cfg.L1Head) + }) + + t.Run("Invalid", func(t *testing.T) { + verifyArgsInvalid(t, config.ErrInvalidL1Head.Error(), replaceRequiredArg("--l1.head", "something")) + }) +} + func TestL1(t *testing.T) { expected := "https://example.com:8545" cfg := configForArgs(t, addRequiredArgs("--l1", expected)) @@ -149,7 +167,8 @@ func TestL1RPCKind(t *testing.T) { // Offline support will be added later, but for now it just bails out with an error func TestOfflineModeNotSupported(t *testing.T) { logger := log.New() - err := FaultProofProgram(logger, config.NewConfig(&chaincfg.Goerli, "genesis.json", common.HexToHash(l2HeadValue))) + cfg := config.NewConfig(&chaincfg.Goerli, "genesis.json", common.HexToHash(l1HeadValue), common.HexToHash(l2HeadValue)) + err := FaultProofProgram(logger, cfg) require.ErrorContains(t, err, "offline mode not supported") } @@ -199,8 +218,9 @@ func replaceRequiredArg(name string, value string) []string { func requiredArgs() map[string]string { return map[string]string{ "--network": "goerli", - "--l2.genesis": "genesis.json", + "--l1.head": l1HeadValue, "--l2.head": l2HeadValue, + "--l2.genesis": "genesis.json", } } diff --git a/op-program/host/config/config.go b/op-program/host/config/config.go index b97da6e6690fd..019f706bd9a24 100644 --- a/op-program/host/config/config.go +++ b/op-program/host/config/config.go @@ -14,6 +14,7 @@ import ( var ( ErrMissingRollupConfig = errors.New("missing rollup config") ErrMissingL2Genesis = errors.New("missing l2 genesis") + ErrInvalidL1Head = errors.New("invalid l1 head") ErrInvalidL2Head = errors.New("invalid l2 head") ErrL1AndL2Inconsistent = errors.New("l1 and l2 options must be specified together or both omitted") ) @@ -22,6 +23,7 @@ type Config struct { Rollup *rollup.Config L2URL string L2GenesisPath string + L1Head common.Hash L2Head common.Hash L1URL string L1TrustRPC bool @@ -35,12 +37,15 @@ func (c *Config) Check() error { if err := c.Rollup.Check(); err != nil { return err } - if c.L2GenesisPath == "" { - return ErrMissingL2Genesis + if c.L1Head == (common.Hash{}) { + return ErrInvalidL1Head } if c.L2Head == (common.Hash{}) { return ErrInvalidL2Head } + if c.L2GenesisPath == "" { + return ErrMissingL2Genesis + } if (c.L1URL != "") != (c.L2URL != "") { return ErrL1AndL2Inconsistent } @@ -52,10 +57,11 @@ func (c *Config) FetchingEnabled() bool { } // NewConfig creates a Config with all optional values set to the CLI default value -func NewConfig(rollupCfg *rollup.Config, l2GenesisPath string, l2Head common.Hash) *Config { +func NewConfig(rollupCfg *rollup.Config, l2GenesisPath string, l1Head common.Hash, l2Head common.Hash) *Config { return &Config{ Rollup: rollupCfg, L2GenesisPath: l2GenesisPath, + L1Head: l1Head, L2Head: l2Head, L1RPCKind: sources.RPCKindBasic, } @@ -73,11 +79,16 @@ func NewConfigFromCLI(ctx *cli.Context) (*Config, error) { if l2Head == (common.Hash{}) { return nil, ErrInvalidL2Head } + l1Head := common.HexToHash(ctx.GlobalString(flags.L1Head.Name)) + if l1Head == (common.Hash{}) { + return nil, ErrInvalidL1Head + } return &Config{ Rollup: rollupCfg, L2URL: ctx.GlobalString(flags.L2NodeAddr.Name), L2GenesisPath: ctx.GlobalString(flags.L2GenesisPath.Name), L2Head: l2Head, + L1Head: l1Head, L1URL: ctx.GlobalString(flags.L1NodeAddr.Name), L1TrustRPC: ctx.GlobalBool(flags.L1TrustRPC.Name), L1RPCKind: sources.RPCProviderKind(ctx.GlobalString(flags.L1RPCProviderKind.Name)), diff --git a/op-program/host/config/config_test.go b/op-program/host/config/config_test.go index ce1fd0483f5a5..365de63ccab12 100644 --- a/op-program/host/config/config_test.go +++ b/op-program/host/config/config_test.go @@ -11,66 +11,58 @@ import ( var validRollupConfig = &chaincfg.Goerli var validL2GenesisPath = "genesis.json" +var validL1Head = common.HexToHash("0x112233889988FF") var validL2Head = common.HexToHash("0x6303578b1fa9480389c51bbcef6fe045bb877da39740819e9eb5f36f94949bd0") func TestDefaultConfigIsValid(t *testing.T) { - err := NewConfig(validRollupConfig, validL2GenesisPath, validL2Head).Check() + err := NewConfig(validRollupConfig, validL2GenesisPath, validL1Head, validL2Head).Check() require.NoError(t, err) } func TestRollupConfig(t *testing.T) { t.Run("Required", func(t *testing.T) { - err := NewConfig(nil, validL2GenesisPath, validL2Head).Check() + err := NewConfig(nil, validL2GenesisPath, validL1Head, validL2Head).Check() require.ErrorIs(t, err, ErrMissingRollupConfig) }) t.Run("Invalid", func(t *testing.T) { - err := NewConfig(&rollup.Config{}, validL2GenesisPath, validL2Head).Check() + err := NewConfig(&rollup.Config{}, validL2GenesisPath, validL1Head, validL2Head).Check() require.ErrorIs(t, err, rollup.ErrBlockTimeZero) }) } -func TestL2Genesis(t *testing.T) { - t.Run("Required", func(t *testing.T) { - err := NewConfig(validRollupConfig, "", validL2Head).Check() - require.ErrorIs(t, err, ErrMissingL2Genesis) - }) - - t.Run("Valid", func(t *testing.T) { - err := NewConfig(validRollupConfig, validL2GenesisPath, validL2Head).Check() - require.NoError(t, err) - }) +func TestL1HeadRequired(t *testing.T) { + err := NewConfig(validRollupConfig, validL2GenesisPath, common.Hash{}, validL2Head).Check() + require.ErrorIs(t, err, ErrInvalidL1Head) } -func TestL2Head(t *testing.T) { - t.Run("Required", func(t *testing.T) { - err := NewConfig(validRollupConfig, validL2GenesisPath, common.Hash{}).Check() - require.ErrorIs(t, err, ErrInvalidL2Head) - }) +func TestL2HeadRequired(t *testing.T) { + err := NewConfig(validRollupConfig, validL2GenesisPath, validL1Head, common.Hash{}).Check() + require.ErrorIs(t, err, ErrInvalidL2Head) +} - t.Run("Valid", func(t *testing.T) { - err := NewConfig(validRollupConfig, validL2GenesisPath, validL2Head).Check() - require.NoError(t, err) - }) +func TestL2GenesisRequired(t *testing.T) { + err := NewConfig(validRollupConfig, "", validL1Head, validL2Head).Check() + require.ErrorIs(t, err, ErrMissingL2Genesis) } func TestFetchingArgConsistency(t *testing.T) { t.Run("RequireL2WhenL1Set", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L1URL = "https://example.com:1234" require.ErrorIs(t, cfg.Check(), ErrL1AndL2Inconsistent) }) t.Run("RequireL1WhenL2Set", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L2URL = "https://example.com:1234" require.ErrorIs(t, cfg.Check(), ErrL1AndL2Inconsistent) }) t.Run("AllowNeitherSet", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) require.NoError(t, cfg.Check()) }) t.Run("AllowBothSet", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L1URL = "https://example.com:1234" cfg.L2URL = "https://example.com:4678" require.NoError(t, cfg.Check()) @@ -79,30 +71,30 @@ func TestFetchingArgConsistency(t *testing.T) { func TestFetchingEnabled(t *testing.T) { t.Run("FetchingNotEnabledWhenNoFetcherUrlsSpecified", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) require.False(t, cfg.FetchingEnabled(), "Should not enable fetching when node URL not supplied") }) t.Run("FetchingEnabledWhenFetcherUrlsSpecified", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L2URL = "https://example.com:1234" require.False(t, cfg.FetchingEnabled(), "Should not enable fetching when node URL not supplied") }) t.Run("FetchingNotEnabledWhenNoL1UrlSpecified", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L2URL = "https://example.com:1234" require.False(t, cfg.FetchingEnabled(), "Should not enable L1 fetching when L1 node URL not supplied") }) t.Run("FetchingNotEnabledWhenNoL2UrlSpecified", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L1URL = "https://example.com:1234" require.False(t, cfg.FetchingEnabled(), "Should not enable L2 fetching when L2 node URL not supplied") }) t.Run("FetchingEnabledWhenBothFetcherUrlsSpecified", func(t *testing.T) { - cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL2Head) + cfg := NewConfig(&chaincfg.Beta1, validL2GenesisPath, validL1Head, validL2Head) cfg.L1URL = "https://example.com:1234" cfg.L2URL = "https://example.com:5678" require.True(t, cfg.FetchingEnabled(), "Should enable fetching when node URL supplied") diff --git a/op-program/host/flags/flags.go b/op-program/host/flags/flags.go index cb5e1bd3f8a06..0e4b39e04d27c 100644 --- a/op-program/host/flags/flags.go +++ b/op-program/host/flags/flags.go @@ -31,16 +31,21 @@ var ( Usage: "Address of L2 JSON-RPC endpoint to use (eth and debug namespace required)", EnvVar: service.PrefixEnvVar(envVarPrefix, "L2_RPC"), } - L2GenesisPath = cli.StringFlag{ - Name: "l2.genesis", - Usage: "Path to the op-geth genesis file", - EnvVar: service.PrefixEnvVar(envVarPrefix, "L2_GENESIS"), + L1Head = cli.StringFlag{ + Name: "l1.head", + Usage: "Hash of the L1 head block. Derivation stops after this block is processed.", + EnvVar: service.PrefixEnvVar(envVarPrefix, "L1_HEAD"), } L2Head = cli.StringFlag{ Name: "l2.head", Usage: "Hash of the agreed L2 block to start derivation from", EnvVar: service.PrefixEnvVar(envVarPrefix, "L2_HEAD"), } + L2GenesisPath = cli.StringFlag{ + Name: "l2.genesis", + Usage: "Path to the op-geth genesis file", + EnvVar: service.PrefixEnvVar(envVarPrefix, "L2_GENESIS"), + } L1NodeAddr = cli.StringFlag{ Name: "l1", Usage: "Address of L1 JSON-RPC endpoint to use (eth namespace required)", @@ -70,8 +75,9 @@ var programFlags = []cli.Flag{ RollupConfig, Network, L2NodeAddr, - L2GenesisPath, + L1Head, L2Head, + L2GenesisPath, L1NodeAddr, L1TrustRPC, L1RPCProviderKind, @@ -97,5 +103,8 @@ func CheckRequired(ctx *cli.Context) error { if ctx.GlobalString(L2Head.Name) == "" { return fmt.Errorf("flag %s is required", L2Head.Name) } + if ctx.GlobalString(L1Head.Name) == "" { + return fmt.Errorf("flag %s is required", L1Head.Name) + } return nil } diff --git a/op-program/host/l1/fetcher.go b/op-program/host/l1/fetcher.go new file mode 100644 index 0000000000000..5760fa73d7dbb --- /dev/null +++ b/op-program/host/l1/fetcher.go @@ -0,0 +1,67 @@ +package l1 + +import ( + "context" + "fmt" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" +) + +type Source interface { + InfoByHash(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, error) + InfoAndTxsByHash(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, types.Transactions, error) + FetchReceipts(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, types.Receipts, error) +} + +type FetchingL1Oracle struct { + ctx context.Context + logger log.Logger + source Source +} + +func NewFetchingL1Oracle(ctx context.Context, logger log.Logger, source Source) *FetchingL1Oracle { + return &FetchingL1Oracle{ + ctx: ctx, + logger: logger, + source: source, + } +} + +func (o FetchingL1Oracle) HeaderByBlockHash(blockHash common.Hash) eth.BlockInfo { + o.logger.Trace("HeaderByBlockHash", "hash", blockHash) + info, err := o.source.InfoByHash(o.ctx, blockHash) + if err != nil { + panic(fmt.Errorf("retrieve block %s: %w", blockHash, err)) + } + if info == nil { + panic(fmt.Errorf("unknown block: %s", blockHash)) + } + return info +} + +func (o FetchingL1Oracle) TransactionsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Transactions) { + o.logger.Trace("TransactionsByBlockHash", "hash", blockHash) + info, txs, err := o.source.InfoAndTxsByHash(o.ctx, blockHash) + if err != nil { + panic(fmt.Errorf("retrieve transactions for block %s: %w", blockHash, err)) + } + if info == nil || txs == nil { + panic(fmt.Errorf("unknown block: %s", blockHash)) + } + return info, txs +} + +func (o FetchingL1Oracle) ReceiptsByBlockHash(blockHash common.Hash) (eth.BlockInfo, types.Receipts) { + o.logger.Trace("ReceiptsByBlockHash", "hash", blockHash) + info, rcpts, err := o.source.FetchReceipts(o.ctx, blockHash) + if err != nil { + panic(fmt.Errorf("retrieve receipts for block %s: %w", blockHash, err)) + } + if info == nil || rcpts == nil { + panic(fmt.Errorf("unknown block: %s", blockHash)) + } + return info, rcpts +} diff --git a/op-program/host/l1/fetcher_test.go b/op-program/host/l1/fetcher_test.go new file mode 100644 index 0000000000000..23006fde7c103 --- /dev/null +++ b/op-program/host/l1/fetcher_test.go @@ -0,0 +1,160 @@ +package l1 + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum-optimism/optimism/op-node/sources" + "github.com/ethereum-optimism/optimism/op-node/testlog" + cll1 "github.com/ethereum-optimism/optimism/op-program/client/l1" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +// Needs to implement the Oracle interface +var _ cll1.Oracle = (*FetchingL1Oracle)(nil) + +// Want to be able to use an L1Client as the data source +var _ Source = (*sources.L1Client)(nil) + +func TestHeaderByHash(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &sources.HeaderInfo{} + source := &stubSource{nextInfo: expected} + oracle := newFetchingOracle(t, source) + + actual := oracle.HeaderByBlockHash(expected.Hash()) + require.Equal(t, expected, actual) + }) + + t.Run("UnknownBlock", func(t *testing.T) { + oracle := newFetchingOracle(t, &stubSource{}) + hash := common.HexToHash("0x4455") + require.PanicsWithError(t, fmt.Errorf("unknown block: %s", hash).Error(), func() { + oracle.HeaderByBlockHash(hash) + }) + }) + + t.Run("Error", func(t *testing.T) { + err := errors.New("kaboom") + source := &stubSource{nextErr: err} + oracle := newFetchingOracle(t, source) + + hash := common.HexToHash("0x8888") + require.PanicsWithError(t, fmt.Errorf("retrieve block %s: %w", hash, err).Error(), func() { + oracle.HeaderByBlockHash(hash) + }) + }) +} + +func TestTransactionsByHash(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expectedInfo := &sources.HeaderInfo{} + expectedTxs := types.Transactions{ + &types.Transaction{}, + } + source := &stubSource{nextInfo: expectedInfo, nextTxs: expectedTxs} + oracle := newFetchingOracle(t, source) + + info, txs := oracle.TransactionsByBlockHash(expectedInfo.Hash()) + require.Equal(t, expectedInfo, info) + require.Equal(t, expectedTxs, txs) + }) + + t.Run("UnknownBlock_NoInfo", func(t *testing.T) { + oracle := newFetchingOracle(t, &stubSource{}) + hash := common.HexToHash("0x4455") + require.PanicsWithError(t, fmt.Errorf("unknown block: %s", hash).Error(), func() { + oracle.TransactionsByBlockHash(hash) + }) + }) + + t.Run("UnknownBlock_NoTxs", func(t *testing.T) { + oracle := newFetchingOracle(t, &stubSource{nextInfo: &sources.HeaderInfo{}}) + hash := common.HexToHash("0x4455") + require.PanicsWithError(t, fmt.Errorf("unknown block: %s", hash).Error(), func() { + oracle.TransactionsByBlockHash(hash) + }) + }) + + t.Run("Error", func(t *testing.T) { + err := errors.New("kaboom") + source := &stubSource{nextErr: err} + oracle := newFetchingOracle(t, source) + + hash := common.HexToHash("0x8888") + require.PanicsWithError(t, fmt.Errorf("retrieve transactions for block %s: %w", hash, err).Error(), func() { + oracle.TransactionsByBlockHash(hash) + }) + }) +} + +func TestReceiptsByHash(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expectedInfo := &sources.HeaderInfo{} + expectedRcpts := types.Receipts{ + &types.Receipt{}, + } + source := &stubSource{nextInfo: expectedInfo, nextRcpts: expectedRcpts} + oracle := newFetchingOracle(t, source) + + info, rcpts := oracle.ReceiptsByBlockHash(expectedInfo.Hash()) + require.Equal(t, expectedInfo, info) + require.Equal(t, expectedRcpts, rcpts) + }) + + t.Run("UnknownBlock_NoInfo", func(t *testing.T) { + oracle := newFetchingOracle(t, &stubSource{}) + hash := common.HexToHash("0x4455") + require.PanicsWithError(t, fmt.Errorf("unknown block: %s", hash).Error(), func() { + oracle.ReceiptsByBlockHash(hash) + }) + }) + + t.Run("UnknownBlock_NoTxs", func(t *testing.T) { + oracle := newFetchingOracle(t, &stubSource{nextInfo: &sources.HeaderInfo{}}) + hash := common.HexToHash("0x4455") + require.PanicsWithError(t, fmt.Errorf("unknown block: %s", hash).Error(), func() { + oracle.ReceiptsByBlockHash(hash) + }) + }) + + t.Run("Error", func(t *testing.T) { + err := errors.New("kaboom") + source := &stubSource{nextErr: err} + oracle := newFetchingOracle(t, source) + + hash := common.HexToHash("0x8888") + require.PanicsWithError(t, fmt.Errorf("retrieve receipts for block %s: %w", hash, err).Error(), func() { + oracle.ReceiptsByBlockHash(hash) + }) + }) +} + +func newFetchingOracle(t *testing.T, source Source) *FetchingL1Oracle { + return NewFetchingL1Oracle(context.Background(), testlog.Logger(t, log.LvlDebug), source) +} + +type stubSource struct { + nextInfo eth.BlockInfo + nextTxs types.Transactions + nextRcpts types.Receipts + nextErr error +} + +func (s stubSource) InfoByHash(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, error) { + return s.nextInfo, s.nextErr +} + +func (s stubSource) InfoAndTxsByHash(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, types.Transactions, error) { + return s.nextInfo, s.nextTxs, s.nextErr +} + +func (s stubSource) FetchReceipts(ctx context.Context, blockHash common.Hash) (eth.BlockInfo, types.Receipts, error) { + return s.nextInfo, s.nextRcpts, s.nextErr +} diff --git a/op-program/host/l1/l1.go b/op-program/host/l1/l1.go index 91f3f5b09f78a..428dffe0ccca0 100644 --- a/op-program/host/l1/l1.go +++ b/op-program/host/l1/l1.go @@ -6,6 +6,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/client" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" "github.com/ethereum-optimism/optimism/op-node/sources" + cll1 "github.com/ethereum-optimism/optimism/op-program/client/l1" "github.com/ethereum-optimism/optimism/op-program/host/config" "github.com/ethereum/go-ethereum/log" ) @@ -16,5 +17,10 @@ func NewFetchingL1(ctx context.Context, logger log.Logger, cfg *config.Config) ( return nil, err } - return sources.NewL1Client(rpc, logger, nil, sources.L1ClientDefaultConfig(cfg.Rollup, cfg.L1TrustRPC, cfg.L1RPCKind)) + source, err := sources.NewL1Client(rpc, logger, nil, sources.L1ClientDefaultConfig(cfg.Rollup, cfg.L1TrustRPC, cfg.L1RPCKind)) + if err != nil { + return nil, err + } + oracle := NewFetchingL1Oracle(ctx, logger, source) + return cll1.NewOracleL1Client(logger, oracle, cfg.L1Head), err }