From b4c313da5c93e8c5109954ef3b2ec4f67a913579 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 23 Jan 2024 15:16:12 +1000 Subject: [PATCH] op-challenger: Support fetching preimage leaf data from transactions (#9122) * op-challenger: Add methods retrieve leaf data from oracle contract bindings. * op-challenger: Add leaf fetcher to extract leaf data from transactions * op-challenger: Remove input length validations from DecodeInputData. * op-challenger: Update fetcher to work with InputData --- op-challenger/game/fault/contracts/oracle.go | 57 +++- .../game/fault/contracts/oracle_test.go | 157 ++++++++- op-challenger/game/keccak/fetcher/fetcher.go | 116 +++++++ .../game/keccak/fetcher/fetcher_test.go | 300 ++++++++++++++++++ op-service/sources/batching/bound.go | 46 +++ op-service/sources/batching/bound_test.go | 53 ++++ op-service/sources/batching/call.go | 24 +- op-service/sources/batching/call_test.go | 14 + 8 files changed, 747 insertions(+), 20 deletions(-) create mode 100644 op-challenger/game/keccak/fetcher/fetcher.go create mode 100644 op-challenger/game/keccak/fetcher/fetcher_test.go create mode 100644 op-service/sources/batching/bound.go create mode 100644 op-service/sources/batching/bound_test.go diff --git a/op-challenger/game/fault/contracts/oracle.go b/op-challenger/game/fault/contracts/oracle.go index 882d7a21f1be..7d3629da0358 100644 --- a/op-challenger/game/fault/contracts/oracle.go +++ b/op-challenger/game/fault/contracts/oracle.go @@ -3,6 +3,7 @@ package contracts import ( "context" "encoding/binary" + "errors" "fmt" "math" "math/big" @@ -24,6 +25,12 @@ const ( methodProposalCount = "proposalCount" methodProposals = "proposals" methodProposalMetadata = "proposalMetadata" + methodProposalBlocksLen = "proposalBlocksLen" + methodProposalBlocks = "proposalBlocks" +) + +var ( + ErrInvalidAddLeavesCall = errors.New("tx is not a valid addLeaves call") ) // PreimageOracleContract is a binding that works with contracts implementing the IPreimageOracle interface @@ -61,7 +68,7 @@ func (p MerkleProof) toSized() [][32]byte { } func NewPreimageOracleContract(addr common.Address, caller *batching.MultiCaller) (*PreimageOracleContract, error) { - mipsAbi, err := bindings.PreimageOracleMetaData.GetAbi() + oracleAbi, err := bindings.PreimageOracleMetaData.GetAbi() if err != nil { return nil, fmt.Errorf("failed to load preimage oracle ABI: %w", err) } @@ -69,7 +76,7 @@ func NewPreimageOracleContract(addr common.Address, caller *batching.MultiCaller return &PreimageOracleContract{ addr: addr, multiCaller: caller, - contract: batching.NewBoundContract(mipsAbi, addr), + contract: batching.NewBoundContract(oracleAbi, addr), }, nil } @@ -167,6 +174,52 @@ func (c *PreimageOracleContract) GetProposalMetadata(ctx context.Context, block return proposals, nil } +func (c *PreimageOracleContract) GetInputDataBlocks(ctx context.Context, block batching.Block, ident keccakTypes.LargePreimageIdent) ([]uint64, error) { + results, err := batching.ReadArray(ctx, c.multiCaller, block, + c.contract.Call(methodProposalBlocksLen, ident.Claimant, ident.UUID), + func(i *big.Int) *batching.ContractCall { + return c.contract.Call(methodProposalBlocks, ident.Claimant, ident.UUID, i) + }) + if err != nil { + return nil, fmt.Errorf("failed to load proposal blocks: %w", err) + } + blockNums := make([]uint64, 0, len(results)) + for _, result := range results { + blockNums = append(blockNums, result.GetUint64(0)) + } + return blockNums, nil +} + +// DecodeInputData returns the UUID and [keccakTypes.InputData] being added to the preimage via a addLeavesLPP call. +// An [ErrInvalidAddLeavesCall] error is returned if the call is not a valid call to addLeavesLPP. +// Otherwise, the uuid and input data is returned. The raw data supplied is returned so long as it can be parsed. +// Specifically the length of the input data is not validated to ensure it is consistent with the number of commitments. +func (c *PreimageOracleContract) DecodeInputData(data []byte) (*big.Int, keccakTypes.InputData, error) { + method, args, err := c.contract.DecodeCall(data) + if errors.Is(err, batching.ErrUnknownMethod) { + return nil, keccakTypes.InputData{}, ErrInvalidAddLeavesCall + } else if err != nil { + return nil, keccakTypes.InputData{}, err + } + if method != methodAddLeavesLPP { + return nil, keccakTypes.InputData{}, fmt.Errorf("%w: %v", ErrInvalidAddLeavesCall, method) + } + uuid := args.GetBigInt(0) + input := args.GetBytes(1) + stateCommitments := args.GetBytes32Slice(2) + finalize := args.GetBool(3) + + commitments := make([]common.Hash, 0, len(stateCommitments)) + for _, c := range stateCommitments { + commitments = append(commitments, c) + } + return uuid, keccakTypes.InputData{ + Input: input, + Commitments: commitments, + Finalize: finalize, + }, nil +} + func (c *PreimageOracleContract) decodePreimageIdent(result *batching.CallResult) keccakTypes.LargePreimageIdent { return keccakTypes.LargePreimageIdent{ Claimant: result.GetAddress(0), diff --git a/op-challenger/game/fault/contracts/oracle_test.go b/op-challenger/game/fault/contracts/oracle_test.go index 64869d2c7598..81e87f4da9ef 100644 --- a/op-challenger/game/fault/contracts/oracle_test.go +++ b/op-challenger/game/fault/contracts/oracle_test.go @@ -78,13 +78,13 @@ func TestPreimageOracleContract_Squeeze(t *testing.T) { uuid := big.NewInt(123) stateMatrix := matrix.NewStateMatrix() preState := keccakTypes.Leaf{ - Input: [136]byte{0x12}, + Input: [keccakTypes.BlockSize]byte{0x12}, Index: big.NewInt(123), StateCommitment: common.Hash{0x34}, } preStateProof := MerkleProof{{0x34}} postState := keccakTypes.Leaf{ - Input: [136]byte{0x34}, + Input: [keccakTypes.BlockSize]byte{0x34}, Index: big.NewInt(456), StateCommitment: common.Hash{0x56}, } @@ -288,3 +288,156 @@ func TestMetadata_Countered(t *testing.T) { meta.setCountered(false) require.False(t, meta.countered()) } + +func TestGetInputDataBlocks(t *testing.T) { + stubRpc, oracle := setupPreimageOracleTest(t) + block := batching.BlockByHash(common.Hash{0xaa}) + + preimage := keccakTypes.LargePreimageIdent{ + Claimant: common.Address{0xbb}, + UUID: big.NewInt(2222), + } + + stubRpc.SetResponse( + oracleAddr, + methodProposalBlocksLen, + block, + []interface{}{preimage.Claimant, preimage.UUID}, + []interface{}{big.NewInt(3)}) + + blockNums := []uint64{10, 35, 67} + + for i, blockNum := range blockNums { + stubRpc.SetResponse( + oracleAddr, + methodProposalBlocks, + block, + []interface{}{preimage.Claimant, preimage.UUID, big.NewInt(int64(i))}, + []interface{}{blockNum}) + } + + actual, err := oracle.GetInputDataBlocks(context.Background(), block, preimage) + require.NoError(t, err) + require.Len(t, actual, 3) + require.Equal(t, blockNums, actual) +} + +func TestDecodeInputData(t *testing.T) { + dataOfLength := func(len int) []byte { + data := make([]byte, len) + for i := range data { + data[i] = byte(i) + } + return data + } + ident := keccakTypes.LargePreimageIdent{ + Claimant: common.Address{0xaa}, + UUID: big.NewInt(1111), + } + _, oracle := setupPreimageOracleTest(t) + + tests := []struct { + name string + input []byte + inputData keccakTypes.InputData + expectedTxData string + expectedErr error + }{ + { + name: "UnknownMethod", + input: []byte{0xaa, 0xbb, 0xcc, 0xdd}, + expectedTxData: "aabbccdd", + expectedErr: ErrInvalidAddLeavesCall, + }, + { + name: "SingleInput", + inputData: keccakTypes.InputData{ + Input: dataOfLength(keccakTypes.BlockSize), + Commitments: []common.Hash{{0xaa}}, + Finalize: false, + }, + expectedTxData: "9f99ef8200000000000000000000000000000000000000000000000000000000000004570000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000088000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80818283848586870000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001aa00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs", + inputData: keccakTypes.InputData{ + Input: dataOfLength(2 * keccakTypes.BlockSize), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: false, + }, + expectedTxData: "9f99ef820000000000000000000000000000000000000000000000000000000000000457000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs-InputTooShort", + inputData: keccakTypes.InputData{ + Input: dataOfLength(2*keccakTypes.BlockSize - 10), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: false, + }, + expectedTxData: "9f99ef820000000000000000000000000000000000000000000000000000000000000457000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000106000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff00010203040500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs-FinalizeDoesNotPadInput", + inputData: keccakTypes.InputData{ + Input: dataOfLength(2*keccakTypes.BlockSize - 15), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: true, + }, + expectedTxData: "9f99ef820000000000000000000000000000000000000000000000000000000000000457000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000101000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs-FinalizePadding-FullBlock", + inputData: keccakTypes.InputData{ + Input: dataOfLength(2 * keccakTypes.BlockSize), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: true, + }, + expectedTxData: "9f99ef820000000000000000000000000000000000000000000000000000000000000457000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000110000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs-FinalizePadding-TrailingZeros", + inputData: keccakTypes.InputData{ + Input: make([]byte, 2*keccakTypes.BlockSize), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: true, + }, + expectedTxData: "9f99ef820000000000000000000000000000000000000000000000000000000000000457000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + { + name: "MultipleInputs-FinalizePadding-ShorterThanSingleBlock", + inputData: keccakTypes.InputData{ + Input: dataOfLength(keccakTypes.BlockSize - 5), + Commitments: []common.Hash{{0xaa}, {0xbb}}, + Finalize: true, + }, + expectedTxData: "9f99ef8200000000000000000000000000000000000000000000000000000000000004570000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000083000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80818200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002aa00000000000000000000000000000000000000000000000000000000000000bb00000000000000000000000000000000000000000000000000000000000000", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + var input []byte + if len(test.input) > 0 { + input = test.input + } else { + input = toAddLeavesTxData(t, oracle, ident.UUID, test.inputData) + } + require.Equal(t, test.expectedTxData, common.Bytes2Hex(input), + "ABI has been changed. Add tests to ensure historic transactions can be parsed before updating expectedTxData") + uuid, leaves, err := oracle.DecodeInputData(input) + if test.expectedErr != nil { + require.ErrorIs(t, err, test.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, ident.UUID, uuid) + require.Equal(t, test.inputData, leaves) + } + }) + } +} + +func toAddLeavesTxData(t *testing.T, oracle *PreimageOracleContract, uuid *big.Int, inputData keccakTypes.InputData) []byte { + tx, err := oracle.AddLeaves(uuid, inputData.Input, inputData.Commitments, inputData.Finalize) + require.NoError(t, err) + return tx.TxData +} diff --git a/op-challenger/game/keccak/fetcher/fetcher.go b/op-challenger/game/keccak/fetcher/fetcher.go new file mode 100644 index 000000000000..fdd7eeb3024c --- /dev/null +++ b/op-challenger/game/keccak/fetcher/fetcher.go @@ -0,0 +1,116 @@ +package fetcher + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts" + keccakTypes "github.com/ethereum-optimism/optimism/op-challenger/game/keccak/types" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" +) + +var ( + ErrNoLeavesFound = errors.New("no leaves found in block") +) + +type L1Source interface { + TxsByNumber(ctx context.Context, number uint64) (types.Transactions, error) + FetchReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + ChainID(ctx context.Context) (*big.Int, error) +} + +type Oracle interface { + Addr() common.Address + GetInputDataBlocks(ctx context.Context, block batching.Block, ident keccakTypes.LargePreimageIdent) ([]uint64, error) + DecodeInputData(data []byte) (*big.Int, keccakTypes.InputData, error) +} + +type InputFetcher struct { + log log.Logger + source L1Source +} + +func (f *InputFetcher) FetchInputs(ctx context.Context, blockHash common.Hash, oracle Oracle, ident keccakTypes.LargePreimageIdent) ([]keccakTypes.InputData, error) { + blockNums, err := oracle.GetInputDataBlocks(ctx, batching.BlockByHash(blockHash), ident) + if err != nil { + return nil, fmt.Errorf("failed to retrieve leaf block nums: %w", err) + } + chainID, err := f.source.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to retrieve L1 chain ID: %w", err) + } + signer := types.LatestSignerForChainID(chainID) + var inputs []keccakTypes.InputData + for _, blockNum := range blockNums { + foundRelevantTx := false + txs, err := f.source.TxsByNumber(ctx, blockNum) + if err != nil { + return nil, fmt.Errorf("failed getting tx for block %v: %w", blockNum, err) + } + for _, tx := range txs { + inputData, err := f.extractRelevantLeavesFromTx(ctx, oracle, signer, tx, ident) + if err != nil { + return nil, err + } + if inputData != nil { + foundRelevantTx = true + inputs = append(inputs, *inputData) + } + } + if !foundRelevantTx { + // The contract said there was a relevant transaction in this block that we failed to find. + // There was either a reorg or the extraction logic is broken. + // Either way, abort this attempt to validate the preimage. + return nil, fmt.Errorf("%w %v", ErrNoLeavesFound, blockNum) + } + } + return inputs, nil +} + +func (f *InputFetcher) extractRelevantLeavesFromTx(ctx context.Context, oracle Oracle, signer types.Signer, tx *types.Transaction, ident keccakTypes.LargePreimageIdent) (*keccakTypes.InputData, error) { + if tx.To() == nil || *tx.To() != oracle.Addr() { + f.log.Trace("Skip tx with incorrect to addr", "tx", tx.Hash(), "expected", oracle.Addr(), "actual", tx.To()) + return nil, nil + } + uuid, inputData, err := oracle.DecodeInputData(tx.Data()) + if errors.Is(err, contracts.ErrInvalidAddLeavesCall) { + f.log.Trace("Skip tx with invalid call data", "tx", tx.Hash(), "err", err) + return nil, nil + } else if err != nil { + return nil, err + } + if uuid.Cmp(ident.UUID) != 0 { + f.log.Trace("Skip tx with incorrect UUID", "tx", tx.Hash(), "expected", ident.UUID, "actual", uuid) + return nil, nil + } + sender, err := signer.Sender(tx) + if err != nil { + f.log.Trace("Skipping transaction with invalid sender", "tx", tx.Hash(), "err", err) + return nil, nil + } + if sender != ident.Claimant { + f.log.Trace("Skipping transaction with incorrect sender", "tx", tx.Hash(), "expected", ident.Claimant, "actual", sender) + return nil, nil + } + rcpt, err := f.source.FetchReceipt(ctx, tx.Hash()) + if err != nil { + return nil, fmt.Errorf("failed to retrieve receipt for tx %v: %w", tx.Hash(), err) + } + if rcpt.Status != types.ReceiptStatusSuccessful { + f.log.Trace("Skipping transaction with failed receipt status", "tx", tx.Hash(), "status", rcpt.Status) + return nil, nil + } + return &inputData, nil +} + +func NewPreimageFetcher(logger log.Logger, source L1Source) *InputFetcher { + return &InputFetcher{ + log: logger, + source: source, + } +} diff --git a/op-challenger/game/keccak/fetcher/fetcher_test.go b/op-challenger/game/keccak/fetcher/fetcher_test.go new file mode 100644 index 000000000000..13c8c5709d38 --- /dev/null +++ b/op-challenger/game/keccak/fetcher/fetcher_test.go @@ -0,0 +1,300 @@ +package fetcher + +import ( + "context" + "crypto/ecdsa" + "errors" + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts" + keccakTypes "github.com/ethereum-optimism/optimism/op-challenger/game/keccak/types" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +var ( + oracleAddr = common.Address{0x99, 0x98} + privKey, _ = crypto.GenerateKey() + ident = keccakTypes.LargePreimageIdent{ + Claimant: crypto.PubkeyToAddress(privKey.PublicKey), + UUID: big.NewInt(888), + } + chainID = big.NewInt(123) + blockHash = common.Hash{0xdd} + input1 = keccakTypes.InputData{ + Input: []byte{0xbb, 0x11}, + Commitments: []common.Hash{{0xcc, 0x11}}, + } + input2 = keccakTypes.InputData{ + Input: []byte{0xbb, 0x22}, + Commitments: []common.Hash{{0xcc, 0x22}}, + } + input3 = keccakTypes.InputData{ + Input: []byte{0xbb, 0x33}, + Commitments: []common.Hash{{0xcc, 0x33}}, + } + input4 = keccakTypes.InputData{ + Input: []byte{0xbb, 0x44}, + Commitments: []common.Hash{{0xcc, 0x44}}, + Finalize: true, + } +) + +func TestFetchLeaves_NoBlocks(t *testing.T) { + fetcher, oracle, _ := setupFetcherTest(t) + oracle.leafBlocks = []uint64{} + leaves, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Empty(t, leaves) +} + +func TestFetchLeaves_SingleTx(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + l1Source.txs[blockNum] = types.Transactions{oracle.txForInput(ValidTx, input1)} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_MultipleBlocksAndLeaves(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + block1 := uint64(7) + block2 := uint64(15) + block3 := uint64(20) + oracle.leafBlocks = []uint64{block1, block2, block3} + l1Source.txs[block1] = types.Transactions{oracle.txForInput(ValidTx, input1)} + l1Source.txs[block2] = types.Transactions{oracle.txForInput(ValidTx, input2)} + l1Source.txs[block3] = types.Transactions{oracle.txForInput(ValidTx, input3), oracle.txForInput(ValidTx, input4)} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1, input2, input3, input4}, inputs) +} + +func TestFetchLeaves_SkipTxToWrongContract(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Valid tx but to a different contract + tx1 := oracle.txForInput(WithToAddr(common.Address{0x88, 0x99, 0x11}), input2) + // Valid tx but without a to addr + tx2 := oracle.txForInput(WithoutToAddr(), input2) + // Valid tx to the correct contract + tx3 := oracle.txForInput(ValidTx, input1) + l1Source.txs[blockNum] = types.Transactions{tx1, tx2, tx3} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_SkipTxWithDifferentUUID(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Valid tx but with a different UUID + tx1 := oracle.txForInput(WithUUID(big.NewInt(874927294)), input2) + // Valid tx + tx2 := oracle.txForInput(ValidTx, input1) + l1Source.txs[blockNum] = types.Transactions{tx1, tx2} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_SkipTxWithInvalidCall(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Call to preimage oracle but fails to decode + tx1 := oracle.txForInput(WithInvalidData(), input2) + // Valid tx + tx2 := oracle.txForInput(ValidTx, input1) + l1Source.txs[blockNum] = types.Transactions{tx1, tx2} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_SkipTxWithInvalidSender(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Call to preimage oracle with different Chain ID + tx1 := oracle.txForInput(WithChainID(big.NewInt(992)), input3) + // Call to preimage oracle with wrong sender + wrongKey, _ := crypto.GenerateKey() + tx2 := oracle.txForInput(WithPrivKey(wrongKey), input4) + // Valid tx + tx3 := oracle.txForInput(ValidTx, input1) + l1Source.txs[blockNum] = types.Transactions{tx1, tx2, tx3} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_SkipTxWithReceiptStatusFail(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Valid call to the preimage oracle but that reverted + tx1 := oracle.txForInput(ValidTx, input2) + l1Source.rcptStatus[tx1.Hash()] = types.ReceiptStatusFailed + // Valid tx + tx2 := oracle.txForInput(ValidTx, input1) + l1Source.txs[blockNum] = types.Transactions{tx1, tx2} + inputs, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.NoError(t, err) + require.Equal(t, []keccakTypes.InputData{input1}, inputs) +} + +func TestFetchLeaves_ErrorsWhenNoValidLeavesInBlock(t *testing.T) { + fetcher, oracle, l1Source := setupFetcherTest(t) + blockNum := uint64(7) + oracle.leafBlocks = []uint64{blockNum} + // Irrelevant call + tx1 := oracle.txForInput(WithUUID(big.NewInt(492)), input2) + l1Source.rcptStatus[tx1.Hash()] = types.ReceiptStatusFailed + l1Source.txs[blockNum] = types.Transactions{tx1} + _, err := fetcher.FetchInputs(context.Background(), blockHash, oracle, ident) + require.ErrorIs(t, err, ErrNoLeavesFound) +} + +func setupFetcherTest(t *testing.T) (*InputFetcher, *stubOracle, *stubL1Source) { + oracle := &stubOracle{ + txInputs: make(map[byte]keccakTypes.InputData), + } + l1Source := &stubL1Source{ + txs: make(map[uint64]types.Transactions), + rcptStatus: make(map[common.Hash]uint64), + } + fetcher := NewPreimageFetcher(testlog.Logger(t, log.LvlTrace), l1Source) + return fetcher, oracle, l1Source +} + +type stubOracle struct { + nextTxId byte + leafBlocks []uint64 + txInputs map[byte]keccakTypes.InputData +} + +func (o *stubOracle) Addr() common.Address { + return oracleAddr +} + +func (o *stubOracle) GetInputDataBlocks(_ context.Context, _ batching.Block, _ keccakTypes.LargePreimageIdent) ([]uint64, error) { + return o.leafBlocks, nil +} + +func (o *stubOracle) DecodeInputData(data []byte) (*big.Int, keccakTypes.InputData, error) { + if len(data) == 0 { + return nil, keccakTypes.InputData{}, contracts.ErrInvalidAddLeavesCall + } + input, ok := o.txInputs[data[0]] + if !ok { + return nil, keccakTypes.InputData{}, contracts.ErrInvalidAddLeavesCall + } + uuid := ident.UUID + // WithUUID appends custom UUIDs to the tx data + if len(data) > 1 { + uuid = new(big.Int).SetBytes(data[1:]) + } + return uuid, input, nil +} + +type TxModifier func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey + +var ValidTx TxModifier = func(_ *types.DynamicFeeTx) *ecdsa.PrivateKey { + return privKey +} + +func WithToAddr(addr common.Address) TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + tx.To = &addr + return privKey + } +} + +func WithoutToAddr() TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + tx.To = nil + return privKey + } +} + +func WithUUID(uuid *big.Int) TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + tx.Data = append(tx.Data, uuid.Bytes()...) + return privKey + } +} + +func WithInvalidData() TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + tx.Data = []byte{} + return privKey + } +} + +func WithChainID(id *big.Int) TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + tx.ChainID = id + return privKey + } +} + +func WithPrivKey(key *ecdsa.PrivateKey) TxModifier { + return func(tx *types.DynamicFeeTx) *ecdsa.PrivateKey { + return key + } +} + +func (o *stubOracle) txForInput(txMod TxModifier, input keccakTypes.InputData) *types.Transaction { + id := o.nextTxId + o.nextTxId++ + o.txInputs[id] = input + inner := &types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + To: &oracleAddr, + Value: big.NewInt(0), + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 3, + Data: []byte{id}, + } + key := txMod(inner) + tx := types.MustSignNewTx(key, types.LatestSignerForChainID(inner.ChainID), inner) + return tx +} + +type stubL1Source struct { + txs map[uint64]types.Transactions + rcptStatus map[common.Hash]uint64 +} + +func (s *stubL1Source) ChainID(_ context.Context) (*big.Int, error) { + return chainID, nil +} + +func (s *stubL1Source) TxsByNumber(_ context.Context, number uint64) (types.Transactions, error) { + txs, ok := s.txs[number] + if !ok { + return nil, errors.New("not found") + } + return txs, nil +} + +func (s *stubL1Source) FetchReceipt(_ context.Context, txHash common.Hash) (*types.Receipt, error) { + rcptStatus, ok := s.rcptStatus[txHash] + if !ok { + rcptStatus = types.ReceiptStatusSuccessful + } + return &types.Receipt{Status: rcptStatus}, nil +} diff --git a/op-service/sources/batching/bound.go b/op-service/sources/batching/bound.go new file mode 100644 index 000000000000..e78817219c28 --- /dev/null +++ b/op-service/sources/batching/bound.go @@ -0,0 +1,46 @@ +package batching + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +var ( + ErrUnknownMethod = errors.New("unknown method") + ErrInvalidCall = errors.New("invalid call") +) + +type BoundContract struct { + abi *abi.ABI + addr common.Address +} + +func NewBoundContract(abi *abi.ABI, addr common.Address) *BoundContract { + return &BoundContract{ + abi: abi, + addr: addr, + } +} + +func (b *BoundContract) Call(method string, args ...interface{}) *ContractCall { + return NewContractCall(b.abi, b.addr, method, args...) +} + +func (b *BoundContract) DecodeCall(data []byte) (string, *CallResult, error) { + if len(data) < 4 { + return "", nil, ErrUnknownMethod + } + method, err := b.abi.MethodById(data[:4]) + if err != nil { + // ABI doesn't return a nicely typed error so treat any failure to find the method as unknown + return "", nil, fmt.Errorf("%w: %v", ErrUnknownMethod, err.Error()) + } + args, err := method.Inputs.Unpack(data[4:]) + if err != nil { + return "", nil, fmt.Errorf("%w: %v", ErrInvalidCall, err.Error()) + } + return method.Name, &CallResult{args}, nil +} diff --git a/op-service/sources/batching/bound_test.go b/op-service/sources/batching/bound_test.go new file mode 100644 index 000000000000..b5672897a575 --- /dev/null +++ b/op-service/sources/batching/bound_test.go @@ -0,0 +1,53 @@ +package batching + +import ( + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-bindings/bindings" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestDecodeCall(t *testing.T) { + method := "approve" + spender := common.Address{0xbb, 0xee} + amount := big.NewInt(4242) + testAbi, err := bindings.ERC20MetaData.GetAbi() + require.NoError(t, err) + validData, err := testAbi.Pack(method, spender, amount) + require.NoError(t, err) + + contract := NewBoundContract(testAbi, common.Address{0xaa}) + t.Run("TooShort", func(t *testing.T) { + _, _, err := contract.DecodeCall([]byte{1, 2, 3}) + require.ErrorIs(t, err, ErrUnknownMethod) + }) + + t.Run("UnknownMethodId", func(t *testing.T) { + _, _, err := contract.DecodeCall([]byte{1, 2, 3, 4}) + require.ErrorIs(t, err, ErrUnknownMethod) + }) + + t.Run("MissingArgs", func(t *testing.T) { + // Truncate to just the 4 byte method selector + _, _, err = contract.DecodeCall(validData[:4]) + require.ErrorIs(t, err, ErrInvalidCall) + + // Truncate to partial args + _, _, err = contract.DecodeCall(validData[:6]) + require.ErrorIs(t, err, ErrInvalidCall) + + // Truncate to first arg but missing second + _, _, err = contract.DecodeCall(validData[:24]) + require.ErrorIs(t, err, ErrInvalidCall) + }) + + t.Run("ValidCall", func(t *testing.T) { + name, args, err := contract.DecodeCall(validData) + require.NoError(t, err) + require.Equal(t, name, method) + require.Equal(t, spender, args.GetAddress(0)) + require.Zero(t, amount.Cmp(args.GetBigInt(1))) + }) +} diff --git a/op-service/sources/batching/call.go b/op-service/sources/batching/call.go index 16f0ecf18e05..4dbfb6160685 100644 --- a/op-service/sources/batching/call.go +++ b/op-service/sources/batching/call.go @@ -11,22 +11,6 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" ) -type BoundContract struct { - abi *abi.ABI - addr common.Address -} - -func NewBoundContract(abi *abi.ABI, addr common.Address) *BoundContract { - return &BoundContract{ - abi: abi, - addr: addr, - } -} - -func (b *BoundContract) Call(method string, args ...interface{}) *ContractCall { - return NewContractCall(b.abi, b.addr, method, args...) -} - type ContractCall struct { Abi *abi.ABI Addr common.Address @@ -134,6 +118,14 @@ func (c *CallResult) GetStruct(i int, target interface{}) { abi.ConvertType(c.out[i], target) } +func (c *CallResult) GetBytes(i int) []byte { + return *abi.ConvertType(c.out[i], new([]byte)).(*[]byte) +} + func (c *CallResult) GetBytes32(i int) [32]byte { return *abi.ConvertType(c.out[i], new([32]byte)).(*[32]byte) } + +func (c *CallResult) GetBytes32Slice(i int) [][32]byte { + return *abi.ConvertType(c.out[i], new([][32]byte)).(*[][32]byte) +} diff --git a/op-service/sources/batching/call_test.go b/op-service/sources/batching/call_test.go index e8a13f842470..f3c6c8649e23 100644 --- a/op-service/sources/batching/call_test.go +++ b/op-service/sources/batching/call_test.go @@ -148,6 +148,13 @@ func TestCallResult_GetValues(t *testing.T) { }, expected: ([32]byte)(common.Hash{0xaa, 0xbb, 0xcc}), }, + { + name: "GetBytes", + getter: func(result *CallResult, i int) interface{} { + return result.GetBytes(i) + }, + expected: []byte{0xaa, 0xbb, 0xcc}, + }, { name: "GetBytes32", getter: func(result *CallResult, i int) interface{} { @@ -155,6 +162,13 @@ func TestCallResult_GetValues(t *testing.T) { }, expected: [32]byte{0xaa, 0xbb, 0xcc}, }, + { + name: "GetBytes32Slice", + getter: func(result *CallResult, i int) interface{} { + return result.GetBytes32Slice(i) + }, + expected: [][32]byte{{0xaa, 0xbb, 0xcc}, {0xdd, 0xee, 0xff}, {0x11, 0x22, 0x33}}, + }, { name: "GetBigInt", getter: func(result *CallResult, i int) interface{} {