diff --git a/crates/optimism/tests/proofs/core/simple_storage_test.go b/crates/optimism/tests/proofs/core/simple_storage_test.go index 1007f501ffa..cab1b89d7bb 100644 --- a/crates/optimism/tests/proofs/core/simple_storage_test.go +++ b/crates/optimism/tests/proofs/core/simple_storage_test.go @@ -5,63 +5,24 @@ import ( "testing" "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/dsl" "github.com/ethereum-optimism/optimism/op-devstack/presets" "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-service/txplan" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "github.com/op-rs/op-geth/proofs/utils" ) -func simpleStorageSetValue(t devtest.T, parsedABI *abi.ABI, user *dsl.EOA, contractAddress common.Address, value *big.Int) *types.Receipt { - ctx := t.Ctx() - callData, err := parsedABI.Pack("setValue", value) - if err != nil { - t.Error("failed to pack set call data: %v", err) - t.FailNow() - } - - callTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&contractAddress), txplan.WithData(callData)) - callRes, err := callTx.Included.Eval(ctx) - if err != nil { - t.Error("failed to create set tx: %v", err) - t.FailNow() - } - - if callRes.Status != types.ReceiptStatusSuccessful { - t.Error("set transaction failed") - t.FailNow() - } - return callRes -} - func TestStorageProofUsingSimpleStorageContract(gt *testing.T) { t := devtest.SerialT(gt) - ctx := t.Ctx() sys := presets.NewSingleChainMultiNode(t) - artifactPath := "../contracts/artifacts/SimpleStorage.sol/SimpleStorage.json" - parsedABI, bin, err := utils.LoadArtifact(artifactPath) - if err != nil { - t.Error("failed to load artifact: %v", err) - t.FailNow() - } - user := sys.FunderL2.NewFundedEOA(eth.OneHundredthEther) // deploy contract via helper - contractAddress, blockNum, err := utils.DeployContract(ctx, user, bin) - if err != nil { - t.Error("failed to deploy contract: %v", err) - t.FailNow() - } - t.Logf("contract deployed at address %s in L2 block %d", contractAddress.Hex(), blockNum) + contract, receipt := utils.DeploySimpleStorage(t, user) + t.Logf("contract deployed at address %s in L2 block %d", contract.Address().Hex(), receipt.BlockNumber.Uint64()) // fetch and verify initial proof (should be zeroed storage) - utils.FetchAndVerifyProofs(t, sys, contractAddress, []common.Hash{common.HexToHash("0x0")}, blockNum) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{common.HexToHash("0x0")}, receipt.BlockNumber.Uint64()) type caseEntry struct { Block uint64 @@ -70,7 +31,7 @@ func TestStorageProofUsingSimpleStorageContract(gt *testing.T) { var cases []caseEntry for i := 1; i <= 5; i++ { writeVal := big.NewInt(int64(i * 10)) - callRes := simpleStorageSetValue(t, &parsedABI, user, contractAddress, writeVal) + callRes := contract.SetValue(user, writeVal) cases = append(cases, caseEntry{ Block: callRes.BlockNumber.Uint64(), @@ -80,7 +41,7 @@ func TestStorageProofUsingSimpleStorageContract(gt *testing.T) { } // test reset storage to zero - callRes := simpleStorageSetValue(t, &parsedABI, user, contractAddress, big.NewInt(0)) + callRes := contract.SetValue(user, big.NewInt(0)) cases = append(cases, caseEntry{ Block: callRes.BlockNumber.Uint64(), Value: big.NewInt(0), @@ -89,61 +50,26 @@ func TestStorageProofUsingSimpleStorageContract(gt *testing.T) { // for each case, get proof and verify for _, c := range cases { - utils.FetchAndVerifyProofs(t, sys, contractAddress, []common.Hash{common.HexToHash("0x0")}, c.Block) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{common.HexToHash("0x0")}, c.Block) } // test with non-existent storage slot nonExistentSlot := common.HexToHash("0xdeadbeef") - utils.FetchAndVerifyProofs(t, sys, contractAddress, []common.Hash{nonExistentSlot}, cases[len(cases)-1].Block) -} - -func multiStorageSetValues(t devtest.T, parsedABI *abi.ABI, user *dsl.EOA, contractAddress common.Address, aVal, bVal *big.Int) *types.Receipt { - ctx := t.Ctx() - callData, err := parsedABI.Pack("setValues", aVal, bVal) - if err != nil { - t.Error("failed to pack set call data: %v", err) - t.FailNow() - } - - callTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&contractAddress), txplan.WithData(callData)) - callRes, err := callTx.Included.Eval(ctx) - if err != nil { - t.Error("failed to create set tx: %v", err) - t.FailNow() - } - - if callRes.Status != types.ReceiptStatusSuccessful { - t.Error("set transaction failed") - t.FailNow() - } - return callRes + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{nonExistentSlot}, cases[len(cases)-1].Block) } func TestStorageProofUsingMultiStorageContract(gt *testing.T) { t := devtest.SerialT(gt) - ctx := t.Ctx() sys := presets.NewSingleChainMultiNode(t) - artifactPath := "../contracts/artifacts/MultiStorage.sol/MultiStorage.json" - parsedABI, bin, err := utils.LoadArtifact(artifactPath) - if err != nil { - t.Error("failed to load artifact: %v", err) - t.FailNow() - } - user := sys.FunderL2.NewFundedEOA(eth.OneHundredthEther) // deploy contract via helper - contractAddress, blockNum, err := utils.DeployContract(ctx, user, bin) - if err != nil { - t.Error("failed to deploy contract: %v", err) - t.FailNow() - } - - t.Logf("contract deployed at address %s in L2 block %d", contractAddress.Hex(), blockNum) + contract, receipt := utils.DeployMultiStorage(t, user) + t.Logf("contract deployed at address %s in L2 block %d", contract.Address().Hex(), receipt.BlockNumber.Uint64()) // fetch and verify initial proof (should be zeroed storage) - utils.FetchAndVerifyProofs(t, sys, contractAddress, []common.Hash{common.HexToHash("0x0"), common.HexToHash("0x1")}, blockNum) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{common.HexToHash("0x0"), common.HexToHash("0x1")}, receipt.BlockNumber.Uint64()) // set multiple storage slots type caseEntry struct { @@ -155,7 +81,7 @@ func TestStorageProofUsingMultiStorageContract(gt *testing.T) { for i := 1; i <= 5; i++ { aVal := big.NewInt(int64(i * 10)) bVal := big.NewInt(int64(i * 20)) - callRes := multiStorageSetValues(t, &parsedABI, user, contractAddress, aVal, bVal) + callRes := contract.SetValues(user, aVal, bVal) cases = append(cases, caseEntry{ Block: callRes.BlockNumber.Uint64(), @@ -168,7 +94,7 @@ func TestStorageProofUsingMultiStorageContract(gt *testing.T) { } // test reset storage slots to zero - callRes := multiStorageSetValues(t, &parsedABI, user, contractAddress, big.NewInt(0), big.NewInt(0)) + callRes := contract.SetValues(user, big.NewInt(0), big.NewInt(0)) cases = append(cases, caseEntry{ Block: callRes.BlockNumber.Uint64(), SlotValues: map[common.Hash]*big.Int{ @@ -185,149 +111,57 @@ func TestStorageProofUsingMultiStorageContract(gt *testing.T) { slots = append(slots, slot) } - utils.FetchAndVerifyProofs(t, sys, contractAddress, slots, c.Block) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), slots, c.Block) } } -// helper: compute mapping slot = keccak256(pad(key) ++ pad(slotIndex)) -func mappingSlot(key common.Address, slotIndex uint64) common.Hash { - keyBytes := common.LeftPadBytes(key.Bytes(), 32) - slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(slotIndex).Bytes(), 32) - return crypto.Keccak256Hash(append(keyBytes, slotBytes...)) -} - -// nested mapping: allowances[owner][spender] where `slotIndex` is the storage slot of allowances mapping -// innerSlot = keccak256(pad(owner) ++ pad(slotIndex)) -// entrySlot = keccak256(pad(spender) ++ innerSlot) -func nestedMappingSlot(owner, spender common.Address, slotIndex uint64) common.Hash { - ownerBytes := common.LeftPadBytes(owner.Bytes(), 32) - slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(slotIndex).Bytes(), 32) - inner := crypto.Keccak256(ownerBytes, slotBytes) - spenderBytes := common.LeftPadBytes(spender.Bytes(), 32) - return crypto.Keccak256Hash(append(spenderBytes, inner...)) -} - -// dynamic array element slot: element k stored at Big(keccak256(p)) + k -func arrayIndexSlot(slotIndex uint64, idx uint64) common.Hash { - slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(slotIndex).Bytes(), 32) - base := crypto.Keccak256(slotBytes) - baseInt := new(big.Int).SetBytes(base) - elem := new(big.Int).Add(baseInt, new(big.Int).SetUint64(idx)) - return common.BigToHash(elem) -} - func TestTokenVaultStorageProofs(gt *testing.T) { t := devtest.SerialT(gt) - ctx := t.Ctx() sys := presets.NewSingleChainMultiNode(t) - artifactPath := "../contracts/artifacts/TokenVault.sol/TokenVault.json" - parsedABI, bin, err := utils.LoadArtifact(artifactPath) - if err != nil { - t.Errorf("failed to load artifact: %v", err) - t.FailNow() - } - // funder EOA that will deploy / interact alice := sys.FunderL2.NewFundedEOA(eth.OneEther) bob := sys.FunderL2.NewFundedEOA(eth.OneEther) // deploy contract - contractAddr, deployBlock, err := utils.DeployContract(ctx, alice, bin) - if err != nil { - t.Errorf("deploy failed: %v", err) - t.FailNow() - } - t.Logf("TokenVault deployed at %s block=%d", contractAddr.Hex(), deployBlock) + contract, deployBlock := utils.DeployTokenVault(t, alice) + t.Logf("TokenVault deployed at %s block=%d", contract.Address().Hex(), deployBlock.BlockNumber.Uint64()) userAddr := alice.Address() // call deposit (payable) depositAmount := eth.OneHundredthEther - depositCalldata, err := parsedABI.Pack("deposit") - if err != nil { - t.Errorf("failed to pack deposit: %v", err) - t.FailNow() - } - depTx := txplan.NewPlannedTx(alice.Plan(), txplan.WithTo(&contractAddr), txplan.WithData(depositCalldata), txplan.WithValue(depositAmount)) - depRes, err := depTx.Included.Eval(ctx) - if err != nil { - t.Errorf("deposit tx failed: %v", err) - t.FailNow() - } - - if depRes.Status != types.ReceiptStatusSuccessful { - t.Error("set transaction failed") - t.FailNow() - } - + depRes := contract.Deposit(alice, depositAmount) depositBlock := depRes.BlockNumber.Uint64() t.Logf("deposit included in block %d", depositBlock) // call approve(spender, amount) - use same user as spender for simplicity, or create another funded EOA approveAmount := big.NewInt(100) spenderAddr := bob.Address() - approveCalldata, err := parsedABI.Pack("approve", spenderAddr, approveAmount) - if err != nil { - t.Errorf("failed to pack approve: %v", err) - t.FailNow() - } - approveTx := txplan.NewPlannedTx(alice.Plan(), txplan.WithTo(&contractAddr), txplan.WithData(approveCalldata)) - approveRes, err := approveTx.Included.Eval(ctx) - if err != nil { - t.Errorf("approve tx failed: %v", err) - t.FailNow() - } - - if approveRes.Status != types.ReceiptStatusSuccessful { - t.Error("approve transaction failed") - t.FailNow() - } - + approveRes := contract.Approve(alice, spenderAddr, approveAmount) approveBlock := approveRes.BlockNumber.Uint64() t.Logf("approve included in block %d", approveBlock) // call deactivateAllowance(spender) - deactCalldata, err := parsedABI.Pack("deactivateAllowance", spenderAddr) - if err != nil { - t.Errorf("failed to pack deactivateAllowance: %v", err) - t.FailNow() - } - deactTx := txplan.NewPlannedTx(alice.Plan(), txplan.WithTo(&contractAddr), txplan.WithData(deactCalldata)) - deactRes, err := deactTx.Included.Eval(ctx) - if err != nil { - t.Errorf("deactivateAllowance tx failed: %v", err) - t.FailNow() - } - - if deactRes.Status != types.ReceiptStatusSuccessful { - t.Error("deactivateAllowance transaction failed") - t.FailNow() - } - + deactRes := contract.DeactivateAllowance(alice, spenderAddr) deactBlock := deactRes.BlockNumber.Uint64() t.Logf("deactivateAllowance included in block %d", deactBlock) - // --- compute storage slots and verify proofs --- - const pBalances = 0 // mapping(address => uint256) slot index - const pAllowances = 1 // mapping(address => mapping(address => uint256)) slot index - const pDepositors = 2 // dynamic array slot index - // balance slot for user - balanceSlot := mappingSlot(userAddr, pBalances) + balanceSlot := contract.GetBalanceSlot(userAddr) // nested allowance slot owner=user, spender=spenderAddr - allowanceSlot := nestedMappingSlot(userAddr, spenderAddr, pAllowances) + allowanceSlot := contract.GetAllowanceSlot(userAddr, spenderAddr) // depositors[0] element slot - depositor0Slot := arrayIndexSlot(pDepositors, 0) + depositor0Slot := contract.GetDepositorSlot(0) // fetch & verify proofs at appropriate blocks // balance after deposit (depositBlock) t.Logf("Verifying balance slot %s at deposit block %d", balanceSlot.Hex(), depositBlock) - utils.FetchAndVerifyProofs(t, sys, contractAddr, []common.Hash{balanceSlot, depositor0Slot}, depositBlock) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{balanceSlot, depositor0Slot}, depositBlock) // allowance after approve (approveBlock) t.Logf("Verifying allowance slot %s at approve block %d", allowanceSlot.Hex(), approveBlock) - utils.FetchAndVerifyProofs(t, sys, contractAddr, []common.Hash{allowanceSlot}, approveBlock) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{allowanceSlot}, approveBlock) // after deactivation, allowance should be zero at deactBlock t.Logf("Verifying allowance slot %s at deactivate block %d", allowanceSlot.Hex(), deactBlock) - utils.FetchAndVerifyProofs(t, sys, contractAddr, []common.Hash{allowanceSlot}, deactBlock) + utils.FetchAndVerifyProofs(t, sys, contract.Address(), []common.Hash{allowanceSlot}, deactBlock) } diff --git a/crates/optimism/tests/proofs/reorg/reorg_test.go b/crates/optimism/tests/proofs/reorg/reorg_test.go index d768b9c96be..b474724d373 100644 --- a/crates/optimism/tests/proofs/reorg/reorg_test.go +++ b/crates/optimism/tests/proofs/reorg/reorg_test.go @@ -1,6 +1,7 @@ package reorg import ( + "math/big" "testing" "time" @@ -15,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestReorgUnsafeHead(gt *testing.T) { +func TestReorgUsingAccountProof(gt *testing.T) { t := devtest.SerialT(gt) ctx := t.Ctx() @@ -31,17 +32,18 @@ func TestReorgUnsafeHead(gt *testing.T) { alice := sys.FunderL2.NewFundedEOA(eth.OneHundredthEther) bob := sys.FunderL2.NewFundedEOA(eth.OneHundredthEther) - // sys.L1Network.WaitForBlock() - time.Sleep(12 * time.Second) - - sys.L2Chain.WaitForBlock() + user := sys.FunderL2.NewFundedEOA(eth.OneEther) + contract, deployBlock := utils.DeploySimpleStorage(t, user) + t.Logf("SimpleStorage deployed at %s block=%d", contract.Address().Hex(), deployBlock.BlockNumber.Uint64()) + time.Sleep(12 * time.Second) divergenceHead := sys.L2Chain.WaitForBlock() // build up some blocks that will be reorged away type caseEntry struct { Block uint64 addr common.Address + slots []common.Hash } var cases []caseEntry for i := 0; i < 3; i++ { @@ -53,10 +55,34 @@ func TestReorgUnsafeHead(gt *testing.T) { cases = append(cases, caseEntry{ Block: receipt.BlockNumber.Uint64(), addr: alice.Address(), + slots: []common.Hash{}, }) cases = append(cases, caseEntry{ Block: receipt.BlockNumber.Uint64(), addr: bob.Address(), + slots: []common.Hash{}, + }) + + // also include the contract account in the proofs to verify + val := big.NewInt(int64(i * 10)) + callRes := contract.SetValue(user, val) + + cases = append(cases, caseEntry{ + Block: callRes.BlockNumber.Uint64(), + addr: contract.Address(), + slots: []common.Hash{common.HexToHash("0x0")}, + }) + } + + // deploy another contract in the reorged blocks + { + rContract, rDeployBlock := utils.DeploySimpleStorage(t, user) + t.Logf("Reorg SimpleStorage deployed at %s block=%d", rContract.Address().Hex(), rDeployBlock.BlockNumber.Uint64()) + + cases = append(cases, caseEntry{ + Block: rDeployBlock.BlockNumber.Uint64(), + addr: rContract.Address(), + slots: []common.Hash{common.HexToHash("0x0")}, }) } @@ -100,10 +126,12 @@ func TestReorgUnsafeHead(gt *testing.T) { cases = append(cases, caseEntry{ Block: divergenceHead.Number, addr: alice.Address(), + slots: []common.Hash{}, }) cases = append(cases, caseEntry{ Block: divergenceHead.Number, addr: bob.Address(), + slots: []common.Hash{}, }) } @@ -151,6 +179,6 @@ func TestReorgUnsafeHead(gt *testing.T) { // verify that the accounts involved in the conflicting blocks for _, c := range cases { - utils.FetchAndVerifyProofs(t, &sys.SingleChainMultiNode, c.addr, []common.Hash{}, c.Block) + utils.FetchAndVerifyProofs(t, &sys.SingleChainMultiNode, c.addr, c.slots, c.Block) } } diff --git a/crates/optimism/tests/proofs/utils/contract.go b/crates/optimism/tests/proofs/utils/contract.go new file mode 100644 index 00000000000..e4e5cffa491 --- /dev/null +++ b/crates/optimism/tests/proofs/utils/contract.go @@ -0,0 +1,26 @@ +package utils + +import ( + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +type Contract struct { + address common.Address + parsedABI abi.ABI +} + +func NewContract(address common.Address, parsedABI abi.ABI) *Contract { + return &Contract{ + address: address, + parsedABI: parsedABI, + } +} + +func (c *Contract) Address() common.Address { + return c.address +} + +func (c *Contract) ABI() abi.ABI { + return c.parsedABI +} diff --git a/crates/optimism/tests/proofs/utils/multistorage.go b/crates/optimism/tests/proofs/utils/multistorage.go new file mode 100644 index 00000000000..9c3ae260f16 --- /dev/null +++ b/crates/optimism/tests/proofs/utils/multistorage.go @@ -0,0 +1,45 @@ +package utils + +import ( + "math/big" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const MultiStorageArtifact = "../contracts/artifacts/MultiStorage.sol/MultiStorage.json" + +type MultiStorage struct { + *Contract + t devtest.T +} + +func (c *MultiStorage) SetValues(user *dsl.EOA, a, b *big.Int) *types.Receipt { + ctx := c.t.Ctx() + callData, err := c.parsedABI.Pack("setValues", a, b) + if err != nil { + require.NoError(c.t, err, "failed to pack set call data") + } + + callTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(callData)) + callRes, err := callTx.Included.Eval(ctx) + if err != nil { + require.NoError(c.t, err, "failed to create set tx") + } + + if callRes.Status != types.ReceiptStatusSuccessful { + require.NoError(c.t, err, "set transaction failed") + } + + return callRes +} + +func DeployMultiStorage(t devtest.T, user *dsl.EOA) (*MultiStorage, *types.Receipt) { + parsedABI, bin := LoadArtifact(t, MultiStorageArtifact) + contractAddress, receipt := DeployContract(t, user, bin) + contract := NewContract(contractAddress, parsedABI) + return &MultiStorage{contract, t}, receipt +} diff --git a/crates/optimism/tests/proofs/utils/proof.go b/crates/optimism/tests/proofs/utils/proof.go new file mode 100644 index 00000000000..c0dc1fb1c77 --- /dev/null +++ b/crates/optimism/tests/proofs/utils/proof.go @@ -0,0 +1,138 @@ +package utils + +import ( + "bytes" + "fmt" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethdb/memorydb" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethereum/go-ethereum/trie" + "github.com/stretchr/testify/require" +) + +// NormalizeProofResponse standardizes an AccountResult obtained from eth_getProof +// across different client implementations (e.g., Geth, Reth) so that they can be +// compared meaningfully in tests. +// +// Ethereum clients may encode empty or zeroed data structures differently while +// still representing the same logical state. For example: +// - An empty storage proof may appear as [] (Geth) or ["0x80"] (Reth). +// +// This function normalizes such differences by: +// - Converting single-element proofs containing "0x80" to an empty proof slice. +func NormalizeProofResponse(res *eth.AccountResult) { + for i := range res.StorageProof { + if len(res.StorageProof[i].Proof) == 1 && bytes.Equal(res.StorageProof[i].Proof[0], []byte{0x80}) { + res.StorageProof[i].Proof = []hexutil.Bytes{} + } + } +} + +// VerifyProof verifies an account and its storage proofs against a given state root. +// +// This function extends the standard behavior of go-ethereum’s AccountResult.Verify() +// by gracefully handling the case where the account’s storage trie root is empty +// (0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421). +func VerifyProof(res *eth.AccountResult, stateRoot common.Hash) error { + // Skip storage proof verification if the storage trie is empty. + if res.StorageHash != types.EmptyRootHash { + for i, entry := range res.StorageProof { + // load all MPT nodes into a DB + db := memorydb.New() + for j, encodedNode := range entry.Proof { + nodeKey := encodedNode + if len(encodedNode) >= 32 { // small MPT nodes are not hashed + nodeKey = crypto.Keccak256(encodedNode) + } + if err := db.Put(nodeKey, encodedNode); err != nil { + return fmt.Errorf("failed to load storage proof node %d of storage value %d into mem db: %w", j, i, err) + } + } + path := crypto.Keccak256(entry.Key) + val, err := trie.VerifyProof(res.StorageHash, path, db) + if err != nil { + return fmt.Errorf("failed to verify storage value %d with key %s (path %x) in storage trie %s: %w", i, entry.Key.String(), path, res.StorageHash, err) + } + if val == nil && entry.Value.ToInt().Cmp(common.Big0) == 0 { // empty storage is zero by default + continue + } + comparison, err := rlp.EncodeToBytes(entry.Value.ToInt().Bytes()) + if err != nil { + return fmt.Errorf("failed to encode storage value %d with key %s (path %x) in storage trie %s: %w", i, entry.Key.String(), path, res.StorageHash, err) + } + if !bytes.Equal(val, comparison) { + return fmt.Errorf("value %d in storage proof does not match proven value at key %s (path %x)", i, entry.Key.String(), path) + } + } + } + + accountClaimed := []any{uint64(res.Nonce), res.Balance.ToInt().Bytes(), res.StorageHash, res.CodeHash} + accountClaimedValue, err := rlp.EncodeToBytes(accountClaimed) + if err != nil { + return fmt.Errorf("failed to encode account from retrieved values: %w", err) + } + + // create a db with all account trie nodes + db := memorydb.New() + for i, encodedNode := range res.AccountProof { + nodeKey := encodedNode + if len(encodedNode) >= 32 { // small MPT nodes are not hashed + nodeKey = crypto.Keccak256(encodedNode) + } + if err := db.Put(nodeKey, encodedNode); err != nil { + return fmt.Errorf("failed to load account proof node %d into mem db: %w", i, err) + } + } + path := crypto.Keccak256(res.Address[:]) + accountProofValue, err := trie.VerifyProof(stateRoot, path, db) + if err != nil { + return fmt.Errorf("failed to verify account value with key %s (path %x) in account trie %s: %w", res.Address, path, stateRoot, err) + } + + if !bytes.Equal(accountClaimedValue, accountProofValue) { + return fmt.Errorf("L1 RPC is tricking us, account proof does not match provided deserialized values:\n"+ + " claimed: %x\n"+ + " proof: %x", accountClaimedValue, accountProofValue) + } + return nil +} + +// FetchAndVerifyProofs fetches account proofs from both L2EL and L2ELB for the given address +func FetchAndVerifyProofs(t devtest.T, sys *presets.SingleChainMultiNode, address common.Address, slots []common.Hash, block uint64) { + ctx := t.Ctx() + gethProofRes, err := sys.L2EL.Escape().L2EthClient().GetProof(ctx, address, slots, hexutil.Uint64(block).String()) + if err != nil { + require.NoError(t, err, "failed to get proof from L2EL at block %d", block) + } + + rethProofRes, err := sys.L2ELB.Escape().L2EthClient().GetProof(ctx, address, slots, hexutil.Uint64(block).String()) + if err != nil { + require.NoError(t, err, "failed to get proof from L2ELB at block %d", block) + } + NormalizeProofResponse(rethProofRes) + NormalizeProofResponse(gethProofRes) + + require.Equal(t, gethProofRes, rethProofRes, "geth and reth proofs should match") + + blockInfo, err := sys.L2EL.Escape().L2EthClient().InfoByNumber(ctx, block) + if err != nil { + require.NoError(t, err, "failed to get block info for block %d", block) + } + + err = VerifyProof(gethProofRes, blockInfo.Root()) + if err != nil { + require.NoError(t, err, "geth proof verification failed at block %d", block) + } + + err = VerifyProof(rethProofRes, blockInfo.Root()) + if err != nil { + require.NoError(t, err, "reth proof verification failed at block %d", block) + } +} diff --git a/crates/optimism/tests/proofs/utils/simplestorage.go b/crates/optimism/tests/proofs/utils/simplestorage.go new file mode 100644 index 00000000000..2e080ff9ad9 --- /dev/null +++ b/crates/optimism/tests/proofs/utils/simplestorage.go @@ -0,0 +1,54 @@ +package utils + +import ( + "math/big" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +const SimpleStorageArtifact = "../contracts/artifacts/SimpleStorage.sol/SimpleStorage.json" + +type SimpleStorage struct { + *Contract + t devtest.T +} + +func (c *SimpleStorage) SetValue(user *dsl.EOA, value *big.Int) *types.Receipt { + ctx := c.t.Ctx() + callData, err := c.parsedABI.Pack("setValue", value) + if err != nil { + require.NoError(c.t, err, "failed to pack set call data") + } + + callTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(callData)) + callRes, err := callTx.Included.Eval(ctx) + if err != nil { + require.NoError(c.t, err, "failed to create set tx") + } + + if callRes.Status != types.ReceiptStatusSuccessful { + require.NoError(c.t, err, "set transaction failed") + } + return callRes +} + +func (c *SimpleStorage) PlanSetValue(user *dsl.EOA, value *big.Int) *txplan.PlannedTx { + callData, err := c.parsedABI.Pack("setValue", value) + if err != nil { + require.NoError(c.t, err, "failed to pack set call data") + } + + callTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(callData)) + return callTx +} + +func DeploySimpleStorage(t devtest.T, user *dsl.EOA) (*SimpleStorage, *types.Receipt) { + parsedABI, bin := LoadArtifact(t, SimpleStorageArtifact) + contractAddress, receipt := DeployContract(t, user, bin) + contract := NewContract(contractAddress, parsedABI) + return &SimpleStorage{contract, t}, receipt +} diff --git a/crates/optimism/tests/proofs/utils/tokenvault.go b/crates/optimism/tests/proofs/utils/tokenvault.go new file mode 100644 index 00000000000..1c1fbe2d12a --- /dev/null +++ b/crates/optimism/tests/proofs/utils/tokenvault.go @@ -0,0 +1,106 @@ +package utils + +import ( + "math/big" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +const TokenVaultArtifact = "../contracts/artifacts/TokenVault.sol/TokenVault.json" +const BalanceSlotIndex = 0 +const AllowanceSlotIndex = 1 +const DepositorSlotIndex = 2 + +type TokenVault struct { + *Contract + t devtest.T +} + +func (c *TokenVault) Deposit(user *dsl.EOA, amount eth.ETH) *types.Receipt { + depositCalldata, err := c.Contract.parsedABI.Pack("deposit") + if err != nil { + require.NoError(c.t, err, "failed to pack deposit calldata") + } + depTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(depositCalldata), txplan.WithValue(amount)) + depRes, err := depTx.Included.Eval(c.t.Ctx()) + if err != nil { + require.NoError(c.t, err, "deposit tx failed") + } + + if depRes.Status != types.ReceiptStatusSuccessful { + require.NoError(c.t, err, "deposit transaction failed") + } + + return depRes +} + +func (c *TokenVault) Approve(user *dsl.EOA, spender common.Address, amount *big.Int) *types.Receipt { + approveCalldata, err := c.Contract.parsedABI.Pack("approve", spender, amount) + if err != nil { + require.NoError(c.t, err, "failed to pack approve calldata") + } + + approveTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(approveCalldata)) + approveRes, err := approveTx.Included.Eval(c.t.Ctx()) + if err != nil { + require.NoError(c.t, err, "approve tx failed") + } + + if approveRes.Status != types.ReceiptStatusSuccessful { + require.NoError(c.t, err, "approve transaction failed") + } + return approveRes +} + +func (c *TokenVault) DeactivateAllowance(user *dsl.EOA, spender common.Address) *types.Receipt { + deactCalldata, err := c.Contract.parsedABI.Pack("deactivateAllowance", spender) + if err != nil { + require.NoError(c.t, err, "failed to pack deactivateAllowance calldata") + } + deactTx := txplan.NewPlannedTx(user.Plan(), txplan.WithTo(&c.Contract.address), txplan.WithData(deactCalldata)) + deactRes, err := deactTx.Included.Eval(c.t.Ctx()) + if err != nil { + require.NoError(c.t, err, "deactivateAllowance tx failed") + } + + if deactRes.Status != types.ReceiptStatusSuccessful { + require.NoError(c.t, err, "deactivateAllowance transaction failed") + } + return deactRes +} + +func (c *TokenVault) GetBalanceSlot(user common.Address) common.Hash { + keyBytes := common.LeftPadBytes(user.Bytes(), 32) + slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(BalanceSlotIndex).Bytes(), 32) + return crypto.Keccak256Hash(append(keyBytes, slotBytes...)) +} + +func (c *TokenVault) GetAllowanceSlot(owner, spender common.Address) common.Hash { + ownerBytes := common.LeftPadBytes(owner.Bytes(), 32) + slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(AllowanceSlotIndex).Bytes(), 32) + inner := crypto.Keccak256(ownerBytes, slotBytes) + spenderBytes := common.LeftPadBytes(spender.Bytes(), 32) + return crypto.Keccak256Hash(append(spenderBytes, inner...)) +} + +func (c *TokenVault) GetDepositorSlot(index uint64) common.Hash { + slotBytes := common.LeftPadBytes(new(big.Int).SetUint64(DepositorSlotIndex).Bytes(), 32) + base := crypto.Keccak256(slotBytes) + baseInt := new(big.Int).SetBytes(base) + elem := new(big.Int).Add(baseInt, new(big.Int).SetUint64(index)) + return common.BigToHash(elem) +} + +func DeployTokenVault(t devtest.T, user *dsl.EOA) (*TokenVault, *types.Receipt) { + parsedABI, bin := LoadArtifact(t, TokenVaultArtifact) + contractAddress, receipt := DeployContract(t, user, bin) + contract := NewContract(contractAddress, parsedABI) + return &TokenVault{contract, t}, receipt +} diff --git a/crates/optimism/tests/proofs/utils/utils.go b/crates/optimism/tests/proofs/utils/utils.go index 75e4c2d4c4c..e47fdc47e15 100644 --- a/crates/optimism/tests/proofs/utils/utils.go +++ b/crates/optimism/tests/proofs/utils/utils.go @@ -1,26 +1,16 @@ package utils import ( - "bytes" - "context" "encoding/json" - "fmt" "os" "strings" "github.com/ethereum-optimism/optimism/op-devstack/devtest" "github.com/ethereum-optimism/optimism/op-devstack/dsl" - "github.com/ethereum-optimism/optimism/op-devstack/presets" - "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/txplan" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethdb/memorydb" - "github.com/ethereum/go-ethereum/rlp" - "github.com/ethereum/go-ethereum/trie" "github.com/stretchr/testify/require" ) @@ -35,157 +25,42 @@ type Artifact struct { // LoadArtifact reads the forge artifact JSON at artifactPath and returns the parsed ABI // and the creation bytecode (as bytes). It prefers bytecode.object (creation) and falls // back to deployedBytecode.object if needed. -func LoadArtifact(artifactPath string) (abi.ABI, []byte, error) { +func LoadArtifact(t devtest.T, artifactPath string) (abi.ABI, []byte) { data, err := os.ReadFile(artifactPath) if err != nil { - return abi.ABI{}, nil, err + require.NoError(t, err, "failed to read artifact file") } + var art Artifact if err := json.Unmarshal(data, &art); err != nil { - return abi.ABI{}, nil, err + require.NoError(t, err, "failed to unmarshal artifact JSON") } + parsedABI, err := abi.JSON(strings.NewReader(string(art.ABI))) if err != nil { - return abi.ABI{}, nil, err + require.NoError(t, err, "failed to parse contract ABI") } + binHex := strings.TrimSpace(art.Bytecode.Object) if binHex == "" { - return parsedABI, nil, fmt.Errorf("artifact missing bytecode") + require.NoError(t, err, "artifact has no bytecode") } - return parsedABI, common.FromHex(binHex), nil + + return parsedABI, common.FromHex(binHex) } // DeployContract deploys the contract creation bytecode from the given artifact. // user must provide a Plan() method compatible with txplan.NewPlannedTx (kept generic). -func DeployContract(ctx context.Context, user *dsl.EOA, bin []byte) (common.Address, uint64, error) { +func DeployContract(t devtest.T, user *dsl.EOA, bin []byte) (common.Address, *types.Receipt) { tx := txplan.NewPlannedTx(user.Plan(), txplan.WithData(bin)) - res, err := tx.Included.Eval(ctx) + res, err := tx.Included.Eval(t.Ctx()) if err != nil { - return common.Address{}, 0, fmt.Errorf("deployment eval: %w", err) + require.NoError(t, err, "contract deployment tx failed") } - return res.ContractAddress, res.BlockNumber.Uint64(), nil -} -// NormalizeProofResponse standardizes an AccountResult obtained from eth_getProof -// across different client implementations (e.g., Geth, Reth) so that they can be -// compared meaningfully in tests. -// -// Ethereum clients may encode empty or zeroed data structures differently while -// still representing the same logical state. For example: -// - An empty storage proof may appear as [] (Geth) or ["0x80"] (Reth). -// -// This function normalizes such differences by: -// - Converting single-element proofs containing "0x80" to an empty proof slice. -func NormalizeProofResponse(res *eth.AccountResult) { - for i := range res.StorageProof { - if len(res.StorageProof[i].Proof) == 1 && bytes.Equal(res.StorageProof[i].Proof[0], []byte{0x80}) { - res.StorageProof[i].Proof = []hexutil.Bytes{} - } - } -} - -// VerifyProof verifies an account and its storage proofs against a given state root. -// -// This function extends the standard behavior of go-ethereum’s AccountResult.Verify() -// by gracefully handling the case where the account’s storage trie root is empty -// (0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421). -func VerifyProof(res *eth.AccountResult, stateRoot common.Hash) error { - // Skip storage proof verification if the storage trie is empty. - if res.StorageHash != types.EmptyRootHash { - for i, entry := range res.StorageProof { - // load all MPT nodes into a DB - db := memorydb.New() - for j, encodedNode := range entry.Proof { - nodeKey := encodedNode - if len(encodedNode) >= 32 { // small MPT nodes are not hashed - nodeKey = crypto.Keccak256(encodedNode) - } - if err := db.Put(nodeKey, encodedNode); err != nil { - return fmt.Errorf("failed to load storage proof node %d of storage value %d into mem db: %w", j, i, err) - } - } - path := crypto.Keccak256(entry.Key) - val, err := trie.VerifyProof(res.StorageHash, path, db) - if err != nil { - return fmt.Errorf("failed to verify storage value %d with key %s (path %x) in storage trie %s: %w", i, entry.Key.String(), path, res.StorageHash, err) - } - if val == nil && entry.Value.ToInt().Cmp(common.Big0) == 0 { // empty storage is zero by default - continue - } - comparison, err := rlp.EncodeToBytes(entry.Value.ToInt().Bytes()) - if err != nil { - return fmt.Errorf("failed to encode storage value %d with key %s (path %x) in storage trie %s: %w", i, entry.Key.String(), path, res.StorageHash, err) - } - if !bytes.Equal(val, comparison) { - return fmt.Errorf("value %d in storage proof does not match proven value at key %s (path %x)", i, entry.Key.String(), path) - } - } + if res.Status != types.ReceiptStatusSuccessful { + require.NoError(t, err, "contract deployment transaction failed") } - accountClaimed := []any{uint64(res.Nonce), res.Balance.ToInt().Bytes(), res.StorageHash, res.CodeHash} - accountClaimedValue, err := rlp.EncodeToBytes(accountClaimed) - if err != nil { - return fmt.Errorf("failed to encode account from retrieved values: %w", err) - } - - // create a db with all account trie nodes - db := memorydb.New() - for i, encodedNode := range res.AccountProof { - nodeKey := encodedNode - if len(encodedNode) >= 32 { // small MPT nodes are not hashed - nodeKey = crypto.Keccak256(encodedNode) - } - if err := db.Put(nodeKey, encodedNode); err != nil { - return fmt.Errorf("failed to load account proof node %d into mem db: %w", i, err) - } - } - path := crypto.Keccak256(res.Address[:]) - accountProofValue, err := trie.VerifyProof(stateRoot, path, db) - if err != nil { - return fmt.Errorf("failed to verify account value with key %s (path %x) in account trie %s: %w", res.Address, path, stateRoot, err) - } - - if !bytes.Equal(accountClaimedValue, accountProofValue) { - return fmt.Errorf("L1 RPC is tricking us, account proof does not match provided deserialized values:\n"+ - " claimed: %x\n"+ - " proof: %x", accountClaimedValue, accountProofValue) - } - return err -} - -// FetchAndVerifyProofs fetches account proofs from both L2EL and L2ELB for the given -func FetchAndVerifyProofs(t devtest.T, sys *presets.SingleChainMultiNode, contractAddress common.Address, slots []common.Hash, block uint64) { - ctx := t.Ctx() - gethProofRes, err := sys.L2EL.Escape().L2EthClient().GetProof(ctx, contractAddress, slots, hexutil.Uint64(block).String()) - if err != nil { - t.Errorf("failed to get proof from L2EL at block %d: %v", block, err) - t.FailNow() - } - - rethProofRes, err := sys.L2ELB.Escape().L2EthClient().GetProof(ctx, contractAddress, slots, hexutil.Uint64(block).String()) - if err != nil { - t.Errorf("failed to get proof from L2ELB at block %d: %v", block, err) - t.FailNow() - } - NormalizeProofResponse(rethProofRes) - - require.Equal(t, gethProofRes, rethProofRes, "geth and reth proofs should match") - - blockInfo, err := sys.L2EL.Escape().L2EthClient().InfoByNumber(ctx, block) - if err != nil { - t.Errorf("failed to get L2 block %d: %v", block, err) - t.FailNow() - } - - err = VerifyProof(gethProofRes, blockInfo.Root()) - if err != nil { - t.Errorf("geth proof verification failed at block %d: %v", block, err) - t.FailNow() - } - - err = VerifyProof(rethProofRes, blockInfo.Root()) - if err != nil { - t.Errorf("reth proof verification failed at block %d: %v", block, err) - t.FailNow() - } + return res.ContractAddress, res }